feat: 对话历史页面租户分组展示功能
- 新增 ConversationHistoryManager.get_tenant_summary() 按租户聚合会话统计 - get_sessions_paginated() 和 get_conversation_analytics() 增加 tenant_id 过滤 - 新增 GET /api/conversations/tenants 租户汇总端点 - sessions 和 analytics API 端点支持 tenant_id 查询参数 - 前端实现租户卡片列表视图和租户详情会话表格视图 - 实现面包屑导航、搜索范围限定、统计面板上下文切换 - 会话删除后自动检测空租户并返回列表视图 - dashboard.html 添加租户视图 DOM 容器 - 交互模式与知识库租户分组视图保持一致
This commit is contained in:
3
.env
3
.env
@@ -21,6 +21,9 @@ DEBUG_MODE=False
|
|||||||
# Logging level for the application. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
# Logging level for the application. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# 租户标识 — 多项目共用同一套代码时,用不同的 TENANT_ID 隔离数据
|
||||||
|
TENANT_ID=default
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# DATABASE CONFIGURATION
|
# DATABASE CONFIGURATION
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ DEBUG_MODE=False
|
|||||||
# Logging level for the application. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
# Logging level for the application. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# 租户标识 — 多项目共用同一套代码时,用不同的 TENANT_ID 隔离数据
|
||||||
|
TENANT_ID=default
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# DATABASE CONFIGURATION
|
# DATABASE CONFIGURATION
|
||||||
|
|||||||
1
.kiro/specs/conversation-tenant-view/.config.kiro
Normal file
1
.kiro/specs/conversation-tenant-view/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "b7e3c1a2-5f84-4d9e-a1b3-8c6d2e4f7a90", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
319
.kiro/specs/conversation-tenant-view/design.md
Normal file
319
.kiro/specs/conversation-tenant-view/design.md
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# Design Document: 对话历史租户分组展示 (conversation-tenant-view)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
本设计将对话历史页面从扁平会话列表改造为两层结构:第一层按 `tenant_id` 分组展示租户汇总卡片(会话总数、消息总数、活跃会话数、最近活跃时间),第二层展示某租户下的具体会话列表。点击会话仍可查看消息详情(保留现有第三层功能)。改造涉及三个层面:
|
||||||
|
|
||||||
|
1. **后端 API 层** — 在 `conversations_bp` 中新增租户汇总端点 `GET /api/conversations/tenants`,并为现有 `/api/conversations/sessions` 和 `/api/conversations/analytics` 端点增加 `tenant_id` 查询参数支持。
|
||||||
|
2. **业务逻辑层** — 在 `ConversationHistoryManager` 中新增 `get_tenant_summary()` 方法,并为 `get_sessions_paginated()`、`get_conversation_analytics()` 方法增加 `tenant_id` 过滤参数。
|
||||||
|
3. **前端展示层** — 在 `dashboard.js` 中实现 `Tenant_List_View` 和 `Tenant_Detail_View` 两个视图状态的切换逻辑,包括面包屑导航、统计面板上下文切换、搜索范围限定。
|
||||||
|
|
||||||
|
数据模型 `ChatSession` 和 `Conversation` 已有 `tenant_id` 字段(`String(50)`, indexed),无需数据库迁移。
|
||||||
|
|
||||||
|
交互模式与知识库租户分组视图(knowledge-tenant-view)保持一致。
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph Frontend["前端 (dashboard.js)"]
|
||||||
|
TLV[Tenant_List_View<br/>租户卡片列表]
|
||||||
|
TDV[Tenant_Detail_View<br/>租户会话列表]
|
||||||
|
MDV[Message_Detail_View<br/>会话消息详情]
|
||||||
|
Stats[统计面板<br/>全局/租户统计切换]
|
||||||
|
Breadcrumb[面包屑导航]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph API["Flask Blueprint (conversations_bp)"]
|
||||||
|
EP1["GET /api/conversations/tenants"]
|
||||||
|
EP2["GET /api/conversations/sessions?tenant_id=X"]
|
||||||
|
EP3["GET /api/conversations/analytics?tenant_id=X"]
|
||||||
|
EP4["GET /api/conversations/sessions/<id>"]
|
||||||
|
EP5["DELETE /api/conversations/sessions/<id>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Service["ConversationHistoryManager"]
|
||||||
|
M1[get_tenant_summary]
|
||||||
|
M2[get_sessions_paginated<br/>+tenant_id filter]
|
||||||
|
M3[get_conversation_analytics<br/>+tenant_id filter]
|
||||||
|
M4[get_session_messages]
|
||||||
|
M5[delete_session]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DB["SQLAlchemy"]
|
||||||
|
CS[ChatSession<br/>tenant_id indexed]
|
||||||
|
CV[Conversation<br/>tenant_id indexed]
|
||||||
|
end
|
||||||
|
|
||||||
|
TLV -->|点击租户卡片| TDV
|
||||||
|
TDV -->|点击会话行| MDV
|
||||||
|
TDV -->|面包屑返回| TLV
|
||||||
|
MDV -->|面包屑返回| TDV
|
||||||
|
|
||||||
|
TLV --> EP1
|
||||||
|
TDV --> EP2
|
||||||
|
Stats --> EP3
|
||||||
|
MDV --> EP4
|
||||||
|
TDV --> EP5
|
||||||
|
|
||||||
|
EP1 --> M1
|
||||||
|
EP2 --> M2
|
||||||
|
EP3 --> M3
|
||||||
|
EP4 --> M4
|
||||||
|
EP5 --> M5
|
||||||
|
|
||||||
|
M1 --> CS
|
||||||
|
M2 --> CS
|
||||||
|
M3 --> CS & CV
|
||||||
|
M4 --> CV
|
||||||
|
M5 --> CS & CV
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设计决策
|
||||||
|
|
||||||
|
- **不引入新模型/表**:`tenant_id` 已存在于 `ChatSession` 和 `Conversation`,聚合查询通过 `GROUP BY` 实现,无需额外的 Tenant 表。
|
||||||
|
- **视图状态管理在前端**:使用 JS 变量 `conversationCurrentTenantId` 控制当前视图层级,避免引入前端路由框架。与 knowledge-tenant-view 的 `currentTenantId` 模式一致。
|
||||||
|
- **统计面板复用**:同一个统计面板根据 `conversationCurrentTenantId` 是否为 `null` 决定请求全局或租户级统计。
|
||||||
|
- **搜索范围自动限定**:当处于 `Tenant_Detail_View` 时,搜索请求自动附加 `tenant_id` 参数。
|
||||||
|
- **复用现有删除逻辑**:`delete_session()` 已实现删除会话及关联消息,无需修改。
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### 1. ConversationHistoryManager 新增/修改方法
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 新增方法
|
||||||
|
def get_tenant_summary(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
按 tenant_id 聚合 ChatSession,返回租户汇总列表。
|
||||||
|
返回格式: [
|
||||||
|
{
|
||||||
|
"tenant_id": "market_a",
|
||||||
|
"session_count": 15,
|
||||||
|
"message_count": 230,
|
||||||
|
"active_session_count": 5,
|
||||||
|
"last_active_time": "2026-03-20T10:30:00"
|
||||||
|
}, ...
|
||||||
|
]
|
||||||
|
按 last_active_time 降序排列。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 修改方法签名
|
||||||
|
def get_sessions_paginated(
|
||||||
|
self,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 20,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
search: str = '',
|
||||||
|
date_filter: str = '',
|
||||||
|
tenant_id: Optional[str] = None # 新增
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
|
||||||
|
def get_conversation_analytics(
|
||||||
|
self,
|
||||||
|
work_order_id: Optional[int] = None,
|
||||||
|
days: int = 7,
|
||||||
|
tenant_id: Optional[str] = None # 新增
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Conversations API 新增/修改端点
|
||||||
|
|
||||||
|
| 端点 | 方法 | 变更 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/conversations/tenants` | GET | 新增 | 返回租户汇总数组 |
|
||||||
|
| `/api/conversations/sessions` | GET | 修改 | 增加 `tenant_id` 查询参数 |
|
||||||
|
| `/api/conversations/analytics` | GET | 修改 | 增加 `tenant_id` 查询参数 |
|
||||||
|
|
||||||
|
现有端点保持不变:
|
||||||
|
- `GET /api/conversations/sessions/<session_id>` — 获取会话消息详情
|
||||||
|
- `DELETE /api/conversations/sessions/<session_id>` — 删除会话
|
||||||
|
|
||||||
|
### 3. 前端组件
|
||||||
|
|
||||||
|
| 组件/函数 | 职责 |
|
||||||
|
|-----------|------|
|
||||||
|
| `loadConversationTenantList()` | 请求 `/api/conversations/tenants`,渲染租户卡片 |
|
||||||
|
| `loadConversationTenantDetail(tenantId, page)` | 请求 `/api/conversations/sessions?tenant_id=X`,渲染会话列表 |
|
||||||
|
| `renderConversationBreadcrumb(tenantId, sessionTitle)` | 渲染面包屑 "对话历史 > {tenant_id}" 或 "对话历史 > {tenant_id} > {session_title}" |
|
||||||
|
| `loadConversationStats(tenantId)` | 根据 tenantId 是否为 null 请求全局/租户统计 |
|
||||||
|
| `searchConversationSessions()` | 搜索时自动附加 `conversationCurrentTenantId` |
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### ChatSession(现有,无变更)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ChatSession(Base):
|
||||||
|
__tablename__ = "chat_sessions"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
tenant_id = Column(String(50), nullable=False, default="default", index=True)
|
||||||
|
session_id = Column(String(100), unique=True, nullable=False)
|
||||||
|
user_id = Column(String(100), nullable=True)
|
||||||
|
work_order_id = Column(Integer, ForeignKey("work_orders.id"), nullable=True)
|
||||||
|
title = Column(String(200), nullable=True)
|
||||||
|
status = Column(String(20), default="active") # active, ended
|
||||||
|
message_count = Column(Integer, default=0)
|
||||||
|
source = Column(String(50), nullable=True)
|
||||||
|
ip_address = Column(String(45), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
ended_at = Column(DateTime, nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conversation(现有,无变更)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Conversation(Base):
|
||||||
|
__tablename__ = "conversations"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
tenant_id = Column(String(50), nullable=False, default="default", index=True)
|
||||||
|
session_id = Column(String(100), ForeignKey("chat_sessions.session_id"), nullable=True)
|
||||||
|
work_order_id = Column(Integer, ForeignKey("work_orders.id"))
|
||||||
|
user_message = Column(Text, nullable=False)
|
||||||
|
assistant_response = Column(Text, nullable=False)
|
||||||
|
timestamp = Column(DateTime, default=datetime.now)
|
||||||
|
confidence_score = Column(Float)
|
||||||
|
response_time = Column(Float)
|
||||||
|
# ... 其他字段
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tenant Summary(API 响应结构,非持久化)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenant_id": "market_a",
|
||||||
|
"session_count": 15,
|
||||||
|
"message_count": 230,
|
||||||
|
"active_session_count": 5,
|
||||||
|
"last_active_time": "2026-03-20T10:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analytics 响应结构(扩展)
|
||||||
|
|
||||||
|
现有 analytics 响应增加 `tenant_id` 字段(仅当按租户筛选时返回),其余结构不变。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: Tenant summary aggregation correctness
|
||||||
|
|
||||||
|
*For any* set of `ChatSession` records with mixed `tenant_id`, `status`, and `message_count` values, calling `get_tenant_summary()` should return a list where each element's `session_count` equals the number of `ChatSession` records for that `tenant_id`, each `message_count` equals the sum of `message_count` fields for that `tenant_id`, and each `active_session_count` equals the count of `ChatSession` records with `status == 'active'` for that `tenant_id`.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.1, 1.2, 1.3, 1.4**
|
||||||
|
|
||||||
|
### Property 2: Tenant summary sorted by last_active_time descending
|
||||||
|
|
||||||
|
*For any* result returned by `get_tenant_summary()`, the list should be sorted such that for every consecutive pair of elements `(a, b)`, `a.last_active_time >= b.last_active_time`.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.5**
|
||||||
|
|
||||||
|
### Property 3: Session filtering by tenant, status, and search
|
||||||
|
|
||||||
|
*For any* combination of `tenant_id`, `status`, and `search` parameters, all sessions returned by `get_sessions_paginated()` should satisfy all specified filter conditions simultaneously. Specifically: every returned session's `tenant_id` matches the requested `tenant_id`, every returned session's `status` matches the `status` filter (if provided), and every returned session's `title` or `session_id` contains the `search` string (if provided).
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1, 2.3**
|
||||||
|
|
||||||
|
### Property 4: Pagination consistency with tenant filter
|
||||||
|
|
||||||
|
*For any* `tenant_id` and valid `page`/`per_page` values, the sessions returned by `get_sessions_paginated(tenant_id=X, page=P, per_page=N)` should be a correct slice of the full filtered result set. The `total` field should equal the count of all matching sessions, `total_pages` should equal `ceil(total / per_page)`, and the number of returned sessions should equal `min(per_page, total - (page-1)*per_page)` when `page <= total_pages`.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.2**
|
||||||
|
|
||||||
|
### Property 5: Session deletion removes session and all associated messages
|
||||||
|
|
||||||
|
*For any* `ChatSession` and its associated `Conversation` records, after calling `delete_session(session_id)`, querying for the `ChatSession` by `session_id` should return no results, and querying for `Conversation` records with that `session_id` should also return no results.
|
||||||
|
|
||||||
|
**Validates: Requirements 6.2**
|
||||||
|
|
||||||
|
### Property 6: Search results scoped to tenant
|
||||||
|
|
||||||
|
*For any* search query and `tenant_id`, all sessions returned by `get_sessions_paginated(search=Q, tenant_id=X)` should have `tenant_id == X`. The result set should be a subset of what `get_sessions_paginated(search=Q)` returns (without tenant filter).
|
||||||
|
|
||||||
|
**Validates: Requirements 7.1, 7.2**
|
||||||
|
|
||||||
|
### Property 7: Analytics scoped to tenant
|
||||||
|
|
||||||
|
*For any* `tenant_id`, the analytics returned by `get_conversation_analytics(tenant_id=X)` should reflect only `ChatSession` and `Conversation` records with `tenant_id == X`. When `tenant_id` is omitted, the analytics should aggregate across all tenants. Specifically, the conversation total count with a tenant filter should be less than or equal to the global total count.
|
||||||
|
|
||||||
|
**Validates: Requirements 8.3, 8.4**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### API 层错误处理
|
||||||
|
|
||||||
|
所有 API 端点使用 try/except 包裹,捕获异常后返回统一错误格式:
|
||||||
|
|
||||||
|
| 异常场景 | HTTP 状态码 | 响应格式 |
|
||||||
|
|----------|------------|---------|
|
||||||
|
| 参数校验失败(如 `page < 1`) | 400 | `{"error": "描述信息"}` |
|
||||||
|
| 数据库查询异常 | 500 | `{"error": "描述信息"}` |
|
||||||
|
| 正常但无数据 | 200 | 空数组 `[]` 或 `{"sessions": [], "total": 0}` |
|
||||||
|
|
||||||
|
### 业务逻辑层错误处理
|
||||||
|
|
||||||
|
- `get_tenant_summary()` — 数据库异常时返回空列表 `[]`,记录 error 日志。
|
||||||
|
- `get_sessions_paginated()` — 异常时返回空结构 `{"sessions": [], "total": 0, ...}`(现有行为保持不变)。
|
||||||
|
- `get_conversation_analytics()` — 异常时返回空字典 `{}`(现有行为保持不变)。
|
||||||
|
- `delete_session()` — 异常时返回 `False`,记录 error 日志(现有行为保持不变)。
|
||||||
|
|
||||||
|
### 前端错误处理
|
||||||
|
|
||||||
|
- API 请求失败时通过 `showNotification(message, 'error')` 展示错误提示。
|
||||||
|
- 网络超时或断连时显示通用错误消息。
|
||||||
|
- 删除操作失败时显示具体失败原因。
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### 测试框架
|
||||||
|
|
||||||
|
- **单元测试**: `pytest`
|
||||||
|
- **属性测试**: `hypothesis`(Python property-based testing 库)
|
||||||
|
- **每个属性测试最少运行 100 次迭代**
|
||||||
|
|
||||||
|
### 属性测试(Property-Based Tests)
|
||||||
|
|
||||||
|
每个 Correctness Property 对应一个属性测试,使用 `hypothesis` 的 `@given` 装饰器生成随机输入。
|
||||||
|
|
||||||
|
测试标签格式: `Feature: conversation-tenant-view, Property {number}: {property_text}`
|
||||||
|
|
||||||
|
| Property | 测试描述 | 生成策略 |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| Property 1 | 生成随机 ChatSession 列表(混合 tenant_id、status、message_count),验证 `get_tenant_summary()` 聚合正确性 | `st.lists(st.builds(ChatSession, tenant_id=st.sampled_from([...]), status=st.sampled_from(['active','ended']), message_count=st.integers(min_value=0, max_value=100)))` |
|
||||||
|
| Property 2 | 验证 `get_tenant_summary()` 返回列表按 last_active_time 降序 | 复用 Property 1 的生成策略 |
|
||||||
|
| Property 3 | 生成随机 tenant_id + status + search 组合,验证过滤结果一致性 | `st.sampled_from(tenant_ids)`, `st.sampled_from(['active','ended',''])`, `st.text(min_size=0, max_size=20)` |
|
||||||
|
| Property 4 | 生成随机 page/per_page,验证分页切片正确性 | `st.integers(min_value=1, max_value=10)` for page/per_page |
|
||||||
|
| Property 5 | 创建随机会话及关联消息,删除后验证两者均不存在 | `st.text(min_size=1, max_size=50)` for session_id, `st.integers(min_value=1, max_value=10)` for message count |
|
||||||
|
| Property 6 | 生成随机搜索词和 tenant_id,验证搜索结果范围 | `st.text()` for query, `st.sampled_from(tenant_ids)` |
|
||||||
|
| Property 7 | 生成随机 tenant_id,验证 analytics 数据与手动聚合一致 | `st.sampled_from(tenant_ids)` + `st.none()` |
|
||||||
|
|
||||||
|
### 单元测试(Unit Tests)
|
||||||
|
|
||||||
|
单元测试聚焦于边界情况和具体示例:
|
||||||
|
|
||||||
|
- **边界**: 无 ChatSession 记录时 `get_tenant_summary()` 返回空数组
|
||||||
|
- **边界**: 不存在的 `tenant_id` 查询返回空列表 + `total=0`
|
||||||
|
- **示例**: 数据库异常时 API 返回 500
|
||||||
|
- **示例**: 删除最后一个会话后租户从汇总中消失
|
||||||
|
- **集成**: 前端 `loadConversationTenantList()` → API → Manager 完整链路
|
||||||
|
|
||||||
|
### 测试配置
|
||||||
|
|
||||||
|
```python
|
||||||
|
from hypothesis import settings
|
||||||
|
|
||||||
|
@settings(max_examples=100)
|
||||||
|
```
|
||||||
|
|
||||||
|
每个属性测试函数头部添加注释引用设计文档中的 Property 编号,例如:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Feature: conversation-tenant-view, Property 1: Tenant summary aggregation correctness
|
||||||
|
@given(sessions=st.lists(chat_session_strategy(), min_size=0, max_size=50))
|
||||||
|
def test_tenant_summary_aggregation(sessions):
|
||||||
|
...
|
||||||
|
```
|
||||||
116
.kiro/specs/conversation-tenant-view/requirements.md
Normal file
116
.kiro/specs/conversation-tenant-view/requirements.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
对话历史租户分组展示功能。当前对话历史页面以扁平的会话列表展示所有 `ChatSession` 记录,缺乏租户(市场)维度的组织结构。本功能将对话历史页面改造为两层结构:第一层按租户分组展示汇总信息(会话总数、消息总数、最近活跃时间等),第二层展示某个租户下的具体会话列表。点击具体会话仍可查看消息详情(保留现有功能)。交互模式与知识库租户分组视图保持一致,包括卡片视图、面包屑导航、搜索范围限定和统计面板上下文切换。
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Dashboard**: Flask + Jinja2 + Bootstrap 5 构建的 Web 管理后台主页面(`dashboard.html`)
|
||||||
|
- **Conversation_Tab**: Dashboard 中 `#conversation-history-tab` 区域,用于展示和管理对话历史
|
||||||
|
- **Conversation_API**: Flask Blueprint `conversations_bp`,提供对话相关的 REST API(`/api/conversations/*`)
|
||||||
|
- **History_Manager**: `ConversationHistoryManager` 类,封装对话历史的数据库查询与业务逻辑
|
||||||
|
- **Tenant**: 租户,即市场标识(如 `market_a`、`market_b`),通过 `ChatSession.tenant_id` 字段区分
|
||||||
|
- **Tenant_Summary**: 租户汇总信息,包含租户 ID、会话总数、消息总数、活跃会话数、最近活跃时间等聚合数据
|
||||||
|
- **Tenant_List_View**: 第一层视图,以卡片形式展示所有租户的对话汇总信息
|
||||||
|
- **Tenant_Detail_View**: 第二层视图,展示某个租户下的具体会话列表(含分页、筛选)
|
||||||
|
- **ChatSession**: SQLAlchemy 数据模型,包含 `tenant_id`、`session_id`、`title`、`status`、`message_count`、`source`、`created_at`、`updated_at` 等字段
|
||||||
|
- **Conversation**: SQLAlchemy 数据模型,表示单条对话消息,包含 `tenant_id`、`session_id`、`user_message`、`assistant_response` 等字段
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: 租户汇总 API
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望后端提供按租户分组的对话会话汇总接口,以便前端展示每个租户的对话统计。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a GET request is sent to `/api/conversations/tenants`, THE Conversation_API SHALL return a JSON array of Tenant_Summary objects, each containing `tenant_id`, `session_count`, `message_count`, `active_session_count`, and `last_active_time`
|
||||||
|
2. THE Conversation_API SHALL compute `session_count` by counting all ChatSession records for each Tenant
|
||||||
|
3. THE Conversation_API SHALL compute `message_count` by summing the `message_count` field of all ChatSession records for each Tenant
|
||||||
|
4. THE Conversation_API SHALL compute `active_session_count` by counting ChatSession records with `status == 'active'` for each Tenant
|
||||||
|
5. THE Conversation_API SHALL sort the Tenant_Summary array by `last_active_time` in descending order
|
||||||
|
6. WHEN no ChatSession records exist, THE Conversation_API SHALL return an empty JSON array with HTTP status 200
|
||||||
|
7. IF a database query error occurs, THEN THE Conversation_API SHALL return an error response with HTTP status 500 and a descriptive error message
|
||||||
|
|
||||||
|
### Requirement 2: 租户会话列表 API
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望后端提供按租户筛选的会话分页接口,以便在点击某个租户后查看该租户下的具体会话列表。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a GET request with query parameter `tenant_id` is sent to `/api/conversations/sessions`, THE Conversation_API SHALL return only the ChatSession records belonging to the specified Tenant
|
||||||
|
2. THE Conversation_API SHALL support pagination via `page` and `per_page` query parameters when filtering by `tenant_id`
|
||||||
|
3. THE Conversation_API SHALL support `status` and `search` query parameters for further filtering within a Tenant
|
||||||
|
4. WHEN the `tenant_id` parameter value does not match any existing ChatSession records, THE Conversation_API SHALL return an empty session list with `total` equal to 0 and HTTP status 200
|
||||||
|
5. THE History_Manager SHALL accept `tenant_id` as a filter parameter in the `get_sessions_paginated` method and return paginated results scoped to the specified Tenant
|
||||||
|
|
||||||
|
### Requirement 3: 租户列表视图(第一层)
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望对话历史页面首先展示按租户分组的汇总卡片,以便快速了解各市场的对话活跃度。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the Conversation_Tab is activated, THE Dashboard SHALL display a Tenant_List_View showing one card per Tenant
|
||||||
|
2. THE Tenant_List_View SHALL display the following information for each Tenant: tenant_id(租户名称), session_count(会话总数), message_count(消息总数), active_session_count(活跃会话数), last_active_time(最近活跃时间)
|
||||||
|
3. WHEN the Tenant_List_View is loading data, THE Dashboard SHALL display a loading spinner in the Conversation_Tab area
|
||||||
|
4. WHEN no tenants exist, THE Dashboard SHALL display a placeholder message indicating that no conversation sessions are available
|
||||||
|
5. THE Tenant_List_View SHALL refresh its data when the user clicks a refresh button
|
||||||
|
|
||||||
|
### Requirement 4: 租户详情视图(第二层)
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望点击某个租户卡片后能查看该租户下的具体会话列表,以便管理和审查对话内容。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user clicks on a Tenant card in the Tenant_List_View, THE Dashboard SHALL transition to the Tenant_Detail_View showing ChatSession records for the selected Tenant
|
||||||
|
2. THE Tenant_Detail_View SHALL display each ChatSession with the following fields: title(会话标题), message_count(消息数), status(状态), source(来源), created_at(创建时间), updated_at(最近更新时间)
|
||||||
|
3. THE Tenant_Detail_View SHALL provide a breadcrumb navigation showing "对话历史 > {tenant_id}" to indicate the current context
|
||||||
|
4. WHEN the user clicks the breadcrumb "对话历史" link, THE Dashboard SHALL navigate back to the Tenant_List_View
|
||||||
|
5. THE Tenant_Detail_View SHALL support pagination with configurable page size
|
||||||
|
6. THE Tenant_Detail_View SHALL support filtering by session status and date range
|
||||||
|
|
||||||
|
### Requirement 5: 会话详情查看(第三层保留)
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望在租户详情视图中点击某个会话后能查看该会话的消息详情,以便审查具体对话内容。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user clicks on a ChatSession row in the Tenant_Detail_View, THE Dashboard SHALL display the message detail view showing all Conversation records for the selected ChatSession
|
||||||
|
2. THE Dashboard SHALL retain the existing message detail display logic and UI layout
|
||||||
|
3. THE Dashboard SHALL provide a breadcrumb navigation showing "对话历史 > {tenant_id} > {session_title}" in the message detail view
|
||||||
|
4. WHEN the user clicks the breadcrumb "{tenant_id}" link, THE Dashboard SHALL navigate back to the Tenant_Detail_View for the corresponding Tenant
|
||||||
|
|
||||||
|
### Requirement 6: 会话管理操作
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望在租户详情视图中能对会话执行删除操作,以便维护对话历史数据。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHILE viewing the Tenant_Detail_View, THE Dashboard SHALL provide a delete button for each ChatSession row
|
||||||
|
2. WHEN a user deletes a ChatSession in the Tenant_Detail_View, THE Conversation_API SHALL delete the ChatSession and all associated Conversation records
|
||||||
|
3. WHEN a user deletes a ChatSession, THE Dashboard SHALL refresh the Tenant_Detail_View to reflect the updated data
|
||||||
|
4. WHEN a user deletes all ChatSession records for a Tenant, THE Dashboard SHALL navigate back to the Tenant_List_View and remove the empty Tenant card
|
||||||
|
5. IF a ChatSession deletion fails, THEN THE Dashboard SHALL display an error notification with the failure reason
|
||||||
|
|
||||||
|
### Requirement 7: 搜索功能适配
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望在租户详情视图中搜索会话时,搜索范围限定在当前租户内,以便精确查找。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHILE viewing the Tenant_Detail_View, THE Dashboard SHALL scope the session search to the currently selected Tenant
|
||||||
|
2. WHEN a search query is submitted in the Tenant_Detail_View, THE Conversation_API SHALL filter search results by the specified `tenant_id`
|
||||||
|
3. WHEN the search query is cleared, THE Dashboard SHALL restore the full paginated session list for the current Tenant
|
||||||
|
4. THE History_Manager search method SHALL accept an optional `tenant_id` parameter to limit search scope
|
||||||
|
|
||||||
|
### Requirement 8: 统计信息适配
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望对话历史统计面板在租户列表视图时展示全局统计,在租户详情视图时展示当前租户的统计,以便获取准确的上下文信息。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHILE the Tenant_List_View is displayed, THE Dashboard SHALL show global conversation statistics (total sessions across all tenants, total messages, total active sessions)
|
||||||
|
2. WHILE the Tenant_Detail_View is displayed, THE Dashboard SHALL show statistics scoped to the selected Tenant
|
||||||
|
3. WHEN a GET request with query parameter `tenant_id` is sent to `/api/conversations/analytics`, THE Conversation_API SHALL return analytics data filtered by the specified Tenant
|
||||||
|
4. WHEN the `tenant_id` parameter is omitted from the analytics request, THE Conversation_API SHALL return global analytics across all tenants
|
||||||
142
.kiro/specs/conversation-tenant-view/tasks.md
Normal file
142
.kiro/specs/conversation-tenant-view/tasks.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Implementation Plan: 对话历史租户分组展示 (conversation-tenant-view)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
将对话历史页面从扁平会话列表改造为两层结构:第一层按 `tenant_id` 分组展示租户汇总卡片,第二层展示租户下的会话列表。改造涉及 ConversationHistoryManager 业务逻辑层、Flask API 层、前端 dashboard.js 三个层面。交互模式与知识库租户分组视图保持一致。
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. ConversationHistoryManager 新增 get_tenant_summary 方法
|
||||||
|
- [x] 1.1 在 `src/dialogue/conversation_history.py` 中新增 `get_tenant_summary()` 方法
|
||||||
|
- 使用 SQLAlchemy `GROUP BY ChatSession.tenant_id` 聚合所有 ChatSession 记录
|
||||||
|
- 计算每个租户的 `session_count`(会话总数)、`message_count`(消息总数,sum of message_count)、`active_session_count`(status=='active' 的会话数)、`last_active_time`(max of updated_at)
|
||||||
|
- 按 `last_active_time` 降序排列
|
||||||
|
- 数据库异常时返回空列表 `[]`,记录 error 日志
|
||||||
|
- 无 ChatSession 记录时返回空列表 `[]`
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
|
||||||
|
|
||||||
|
- [ ]* 1.2 为 get_tenant_summary 编写属性测试
|
||||||
|
- **Property 1: Tenant summary aggregation correctness**
|
||||||
|
- **Property 2: Tenant summary sorted by last_active_time descending**
|
||||||
|
- 使用 `hypothesis` 生成随机 ChatSession 列表(混合 tenant_id、status、message_count),验证聚合正确性和排序
|
||||||
|
- **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5**
|
||||||
|
|
||||||
|
- [x] 2. ConversationHistoryManager 现有方法增加 tenant_id 过滤
|
||||||
|
- [x] 2.1 为 `get_sessions_paginated()` 增加 `tenant_id` 可选参数
|
||||||
|
- 在 `src/dialogue/conversation_history.py` 中修改方法签名,增加 `tenant_id: Optional[str] = None`
|
||||||
|
- 当 `tenant_id` 不为 None 时,在查询中增加 `ChatSession.tenant_id == tenant_id` 过滤条件
|
||||||
|
- 返回结构不变,仅过滤范围缩小
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.5_
|
||||||
|
|
||||||
|
- [ ]* 2.2 为 get_sessions_paginated 的 tenant_id 过滤编写属性测试
|
||||||
|
- **Property 3: Session filtering by tenant, status, and search**
|
||||||
|
- **Property 4: Pagination consistency with tenant filter**
|
||||||
|
- **Validates: Requirements 2.1, 2.2, 2.3**
|
||||||
|
|
||||||
|
- [x] 2.3 为 `get_conversation_analytics()` 增加 `tenant_id` 可选参数
|
||||||
|
- 当 `tenant_id` 不为 None 时,所有统计查询增加 `ChatSession.tenant_id == tenant_id` 和 `Conversation.tenant_id == tenant_id` 过滤
|
||||||
|
- 返回结构不变
|
||||||
|
- _Requirements: 8.3, 8.4_
|
||||||
|
|
||||||
|
- [ ]* 2.4 为 get_conversation_analytics 的 tenant_id 过滤编写属性测试
|
||||||
|
- **Property 7: Analytics scoped to tenant**
|
||||||
|
- **Validates: Requirements 8.3, 8.4**
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint - 确保后端业务逻辑层完成
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 4. Conversations API 层新增和修改端点
|
||||||
|
- [x] 4.1 在 `src/web/blueprints/conversations.py` 中新增 `GET /api/conversations/tenants` 端点
|
||||||
|
- 调用 `history_manager.get_tenant_summary()` 返回租户汇总 JSON 数组
|
||||||
|
- 使用 try/except 包裹,异常时返回 HTTP 500
|
||||||
|
- _Requirements: 1.1, 1.5, 1.6, 1.7_
|
||||||
|
|
||||||
|
- [x] 4.2 修改 `GET /api/conversations/sessions` 端点,增加 `tenant_id` 查询参数支持
|
||||||
|
- 从 `request.args` 获取 `tenant_id` 参数,传递给 `history_manager.get_sessions_paginated()`
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||||
|
|
||||||
|
- [x] 4.3 修改 `GET /api/conversations/analytics` 端点,增加 `tenant_id` 查询参数支持
|
||||||
|
- 从 `request.args` 获取 `tenant_id` 参数,传递给 `history_manager.get_conversation_analytics()`
|
||||||
|
- _Requirements: 8.3, 8.4_
|
||||||
|
|
||||||
|
- [ ]* 4.4 为新增和修改的 API 端点编写单元测试
|
||||||
|
- 测试 `/api/conversations/tenants` 返回正确的汇总数据
|
||||||
|
- 测试各端点的 `tenant_id` 参数过滤行为
|
||||||
|
- 测试空数据和异常情况
|
||||||
|
- _Requirements: 1.1, 1.6, 1.7, 2.4_
|
||||||
|
|
||||||
|
- [x] 5. Checkpoint - 确保后端 API 层完成
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 6. 前端 Tenant_List_View(租户列表视图)
|
||||||
|
- [x] 6.1 在 `src/web/static/js/dashboard.js` 中实现 `loadConversationTenantList()` 函数
|
||||||
|
- 请求 `GET /api/conversations/tenants` 获取租户汇总数据
|
||||||
|
- 渲染租户卡片列表,每张卡片展示 `tenant_id`、`session_count`、`message_count`、`active_session_count`、`last_active_time`
|
||||||
|
- 添加加载中 spinner 状态
|
||||||
|
- 无租户时展示空状态占位提示
|
||||||
|
- 卡片点击事件绑定,调用 `loadConversationTenantDetail(tenantId)`
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4_
|
||||||
|
|
||||||
|
- [x] 6.2 实现刷新按钮功能
|
||||||
|
- 在对话历史 tab 区域添加刷新按钮,点击时重新调用 `loadConversationTenantList()`
|
||||||
|
- _Requirements: 3.5_
|
||||||
|
|
||||||
|
- [x] 7. 前端 Tenant_Detail_View(租户详情视图)
|
||||||
|
- [x] 7.1 实现 `loadConversationTenantDetail(tenantId, page)` 函数
|
||||||
|
- 请求 `GET /api/conversations/sessions?tenant_id=X&page=P&per_page=N` 获取会话列表
|
||||||
|
- 渲染会话表格,展示 title、message_count、status、source、created_at、updated_at
|
||||||
|
- 实现分页控件
|
||||||
|
- 支持 status 和 date_filter 筛选
|
||||||
|
- _Requirements: 4.1, 4.2, 4.5, 4.6_
|
||||||
|
|
||||||
|
- [x] 7.2 实现面包屑导航 `renderConversationBreadcrumb(tenantId, sessionTitle)`
|
||||||
|
- 展示 "对话历史 > {tenant_id}" 面包屑(租户详情视图)
|
||||||
|
- 展示 "对话历史 > {tenant_id} > {session_title}" 面包屑(消息详情视图)
|
||||||
|
- 点击 "对话历史" 链接时调用 `loadConversationTenantList()` 返回租户列表视图
|
||||||
|
- 点击 "{tenant_id}" 链接时调用 `loadConversationTenantDetail(tenantId)` 返回租户详情视图
|
||||||
|
- 管理 `conversationCurrentTenantId` 状态变量控制视图层级
|
||||||
|
- _Requirements: 4.3, 4.4, 5.3, 5.4_
|
||||||
|
|
||||||
|
- [x] 7.3 在 Tenant_Detail_View 中集成会话管理操作
|
||||||
|
- 每行会话提供删除按钮,调用 `DELETE /api/conversations/sessions/<session_id>`
|
||||||
|
- 删除成功后刷新当前租户详情视图
|
||||||
|
- 删除所有会话后自动返回租户列表视图并移除空租户卡片
|
||||||
|
- 操作失败时通过 `showNotification` 展示错误提示
|
||||||
|
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
|
||||||
|
|
||||||
|
- [ ]* 7.4 为删除操作编写属性测试
|
||||||
|
- **Property 5: Session deletion removes session and all associated messages**
|
||||||
|
- **Validates: Requirements 6.2**
|
||||||
|
|
||||||
|
- [x] 8. 前端搜索和统计面板适配
|
||||||
|
- [x] 8.1 修改搜索功能 `searchConversationSessions()`
|
||||||
|
- 在 Tenant_Detail_View 中搜索时自动附加 `tenant_id` 参数
|
||||||
|
- 清空搜索时恢复当前租户的完整分页列表
|
||||||
|
- _Requirements: 7.1, 7.2, 7.3_
|
||||||
|
|
||||||
|
- [ ]* 8.2 为搜索范围限定编写属性测试
|
||||||
|
- **Property 6: Search results scoped to tenant**
|
||||||
|
- **Validates: Requirements 7.1, 7.2**
|
||||||
|
|
||||||
|
- [x] 8.3 修改 `loadConversationStats(tenantId)` 函数
|
||||||
|
- 当 `conversationCurrentTenantId` 为 null 时请求全局统计
|
||||||
|
- 当 `conversationCurrentTenantId` 有值时请求 `GET /api/conversations/analytics?tenant_id=X`
|
||||||
|
- _Requirements: 8.1, 8.2_
|
||||||
|
|
||||||
|
- [x] 9. 前端 HTML 模板更新
|
||||||
|
- [x] 9.1 在 `src/web/templates/dashboard.html` 的 `#conversation-history-tab` 区域添加必要的 DOM 容器
|
||||||
|
- 添加面包屑容器、租户卡片列表容器、租户详情容器
|
||||||
|
- 确保与现有 Bootstrap 5 样式一致,与知识库租户视图风格统一
|
||||||
|
- _Requirements: 3.1, 4.3_
|
||||||
|
|
||||||
|
- [x] 10. Final checkpoint - 确保所有功能集成完成
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation
|
||||||
|
- Property tests validate universal correctness properties from the design document
|
||||||
|
- 数据模型 `ChatSession` 和 `Conversation` 已有 `tenant_id` 字段且已建索引,无需数据库迁移
|
||||||
|
- 交互模式与知识库租户分组视图 (knowledge-tenant-view) 保持一致
|
||||||
1
.kiro/specs/knowledge-tenant-view/.config.kiro
Normal file
1
.kiro/specs/knowledge-tenant-view/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "0d6981a4-ab44-429e-966d-0874ce82383c", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
310
.kiro/specs/knowledge-tenant-view/design.md
Normal file
310
.kiro/specs/knowledge-tenant-view/design.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# Design Document: 知识库租户分组展示 (knowledge-tenant-view)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
本设计将知识库管理页面从扁平列表改造为两层结构:第一层按 `tenant_id` 分组展示租户汇总卡片,第二层展示某租户下的知识条目列表。改造涉及三个层面:
|
||||||
|
|
||||||
|
1. **后端 API 层** — 在 `knowledge_bp` 中新增租户汇总端点 `/api/knowledge/tenants`,并为现有 `/api/knowledge` 和 `/api/knowledge/stats` 端点增加 `tenant_id` 查询参数支持。
|
||||||
|
2. **业务逻辑层** — 在 `KnowledgeManager` 中新增 `get_tenant_summary()` 方法,并为 `get_knowledge_paginated()`、`search_knowledge()`、`get_knowledge_stats()` 方法增加 `tenant_id` 过滤参数。`add_knowledge_entry()` 方法也需接受 `tenant_id` 参数。
|
||||||
|
3. **前端展示层** — 在 `dashboard.js` 中实现 `Tenant_List_View` 和 `Tenant_Detail_View` 两个视图状态的切换逻辑,包括面包屑导航、统计面板上下文切换、搜索范围限定。
|
||||||
|
|
||||||
|
数据模型 `KnowledgeEntry` 已有 `tenant_id` 字段(`String(50)`, indexed),无需数据库迁移。
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph Frontend["前端 (dashboard.js)"]
|
||||||
|
TLV[Tenant_List_View<br/>租户卡片列表]
|
||||||
|
TDV[Tenant_Detail_View<br/>租户知识条目列表]
|
||||||
|
Stats[统计面板<br/>全局/租户统计切换]
|
||||||
|
Breadcrumb[面包屑导航]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph API["Flask Blueprint (knowledge_bp)"]
|
||||||
|
EP1["GET /api/knowledge/tenants"]
|
||||||
|
EP2["GET /api/knowledge?tenant_id=X"]
|
||||||
|
EP3["GET /api/knowledge/stats?tenant_id=X"]
|
||||||
|
EP4["GET /api/knowledge/search?q=...&tenant_id=X"]
|
||||||
|
EP5["POST /api/knowledge (含 tenant_id)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Service["KnowledgeManager"]
|
||||||
|
M1[get_tenant_summary]
|
||||||
|
M2[get_knowledge_paginated<br/>+tenant_id filter]
|
||||||
|
M3[get_knowledge_stats<br/>+tenant_id filter]
|
||||||
|
M4[search_knowledge<br/>+tenant_id filter]
|
||||||
|
M5[add_knowledge_entry<br/>+tenant_id param]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DB["SQLAlchemy"]
|
||||||
|
KE[KnowledgeEntry<br/>tenant_id indexed]
|
||||||
|
end
|
||||||
|
|
||||||
|
TLV -->|点击租户卡片| TDV
|
||||||
|
TDV -->|面包屑返回| TLV
|
||||||
|
TLV --> EP1
|
||||||
|
TDV --> EP2
|
||||||
|
TDV --> EP4
|
||||||
|
Stats --> EP3
|
||||||
|
TDV --> EP5
|
||||||
|
|
||||||
|
EP1 --> M1
|
||||||
|
EP2 --> M2
|
||||||
|
EP3 --> M3
|
||||||
|
EP4 --> M4
|
||||||
|
EP5 --> M5
|
||||||
|
|
||||||
|
M1 --> KE
|
||||||
|
M2 --> KE
|
||||||
|
M3 --> KE
|
||||||
|
M4 --> KE
|
||||||
|
M5 --> KE
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设计决策
|
||||||
|
|
||||||
|
- **不引入新模型/表**:`tenant_id` 已存在于 `KnowledgeEntry`,聚合查询通过 `GROUP BY` 实现,无需额外的 Tenant 表。
|
||||||
|
- **视图状态管理在前端**:使用 JS 变量 `currentTenantId` 控制当前视图层级,避免引入前端路由框架。
|
||||||
|
- **统计面板复用**:同一个统计面板根据 `currentTenantId` 是否为 `null` 决定请求全局或租户级统计。
|
||||||
|
- **搜索范围自动限定**:当处于 `Tenant_Detail_View` 时,搜索请求自动附加 `tenant_id` 参数。
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### 1. KnowledgeManager 新增/修改方法
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 新增方法
|
||||||
|
def get_tenant_summary(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
按 tenant_id 聚合活跃知识条目,返回租户汇总列表。
|
||||||
|
返回格式: [
|
||||||
|
{
|
||||||
|
"tenant_id": "market_a",
|
||||||
|
"entry_count": 42,
|
||||||
|
"verified_count": 30,
|
||||||
|
"category_distribution": {"FAQ": 20, "故障排查": 22}
|
||||||
|
}, ...
|
||||||
|
]
|
||||||
|
按 entry_count 降序排列。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 修改方法签名
|
||||||
|
def get_knowledge_paginated(
|
||||||
|
self, page=1, per_page=10,
|
||||||
|
category_filter='', verified_filter='',
|
||||||
|
tenant_id: Optional[str] = None # 新增
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
|
||||||
|
def search_knowledge(
|
||||||
|
self, query: str, top_k=3,
|
||||||
|
verified_only=True,
|
||||||
|
tenant_id: Optional[str] = None # 新增
|
||||||
|
) -> List[Dict[str, Any]]
|
||||||
|
|
||||||
|
def get_knowledge_stats(
|
||||||
|
self,
|
||||||
|
tenant_id: Optional[str] = None # 新增
|
||||||
|
) -> Dict[str, Any]
|
||||||
|
|
||||||
|
def add_knowledge_entry(
|
||||||
|
self, question, answer, category,
|
||||||
|
confidence_score=0.5, is_verified=False,
|
||||||
|
tenant_id: Optional[str] = None # 新增,默认取 config
|
||||||
|
) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Knowledge API 新增/修改端点
|
||||||
|
|
||||||
|
| 端点 | 方法 | 变更 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/knowledge/tenants` | GET | 新增 | 返回租户汇总数组 |
|
||||||
|
| `/api/knowledge` | GET | 修改 | 增加 `tenant_id` 查询参数 |
|
||||||
|
| `/api/knowledge/stats` | GET | 修改 | 增加 `tenant_id` 查询参数 |
|
||||||
|
| `/api/knowledge/search` | GET | 修改 | 增加 `tenant_id` 查询参数 |
|
||||||
|
| `/api/knowledge` | POST | 修改 | 请求体增加 `tenant_id` 字段 |
|
||||||
|
|
||||||
|
### 3. 前端组件
|
||||||
|
|
||||||
|
| 组件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `loadTenantList()` | 请求 `/api/knowledge/tenants`,渲染租户卡片 |
|
||||||
|
| `loadTenantDetail(tenantId, page)` | 请求 `/api/knowledge?tenant_id=X`,渲染知识条目列表 |
|
||||||
|
| `renderBreadcrumb(tenantId)` | 渲染面包屑 "知识库 > {tenant_id}" |
|
||||||
|
| `loadKnowledgeStats(tenantId)` | 根据 tenantId 是否为 null 请求全局/租户统计 |
|
||||||
|
| `searchKnowledge()` | 搜索时自动附加 `currentTenantId` |
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### KnowledgeEntry(现有,无变更)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class KnowledgeEntry(Base):
|
||||||
|
__tablename__ = "knowledge_entries"
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
tenant_id = Column(String(50), nullable=False, default="default", index=True)
|
||||||
|
question = Column(Text, nullable=False)
|
||||||
|
answer = Column(Text, nullable=False)
|
||||||
|
category = Column(String(100), nullable=False)
|
||||||
|
confidence_score = Column(Float, default=0.0)
|
||||||
|
usage_count = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_verified = Column(Boolean, default=False)
|
||||||
|
verified_by = Column(String(100))
|
||||||
|
verified_at = Column(DateTime)
|
||||||
|
vector_embedding = Column(Text)
|
||||||
|
search_frequency = Column(Integer, default=0)
|
||||||
|
last_accessed = Column(DateTime)
|
||||||
|
relevance_score = Column(Float)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tenant Summary(API 响应结构,非持久化)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenant_id": "market_a",
|
||||||
|
"entry_count": 42,
|
||||||
|
"verified_count": 30,
|
||||||
|
"category_distribution": {
|
||||||
|
"FAQ": 20,
|
||||||
|
"故障排查": 22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats 响应结构(扩展)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_entries": 100,
|
||||||
|
"active_entries": 80,
|
||||||
|
"category_distribution": {"FAQ": 40, "故障排查": 60},
|
||||||
|
"average_confidence": 0.85,
|
||||||
|
"tenant_id": "market_a" // 新增,仅当按租户筛选时返回
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||||
|
|
||||||
|
### Property 1: Tenant summary correctly aggregates active entries
|
||||||
|
|
||||||
|
*For any* set of `KnowledgeEntry` records with mixed `is_active` and `tenant_id` values, calling `get_tenant_summary()` should return a list where each element's `entry_count` equals the number of active entries for that `tenant_id`, each `verified_count` equals the number of active+verified entries for that `tenant_id`, and each `category_distribution` correctly reflects the category counts of active entries for that `tenant_id`.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.1, 1.2**
|
||||||
|
|
||||||
|
### Property 2: Tenant summary sorted by entry_count descending
|
||||||
|
|
||||||
|
*For any* result returned by `get_tenant_summary()`, the list should be sorted such that for every consecutive pair of elements `(a, b)`, `a.entry_count >= b.entry_count`.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.3**
|
||||||
|
|
||||||
|
### Property 3: Knowledge entry filtering by tenant, category, and verified status
|
||||||
|
|
||||||
|
*For any* combination of `tenant_id`, `category_filter`, and `verified_filter` parameters, all entries returned by `get_knowledge_paginated()` should satisfy all specified filter conditions simultaneously. Specifically: every returned entry's `tenant_id` matches the requested `tenant_id`, every returned entry's `category` matches `category_filter` (if provided), and every returned entry's `is_verified` matches `verified_filter` (if provided).
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1, 2.3**
|
||||||
|
|
||||||
|
### Property 4: Pagination consistency with tenant filter
|
||||||
|
|
||||||
|
*For any* `tenant_id` and valid `page`/`per_page` values, the entries returned by `get_knowledge_paginated(tenant_id=X, page=P, per_page=N)` should be a correct slice of the full filtered result set. The `total` field should equal the count of all matching entries, `total_pages` should equal `ceil(total / per_page)`, and the number of returned entries should equal `min(per_page, total - (page-1)*per_page)` when `page <= total_pages`.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.2**
|
||||||
|
|
||||||
|
### Property 5: New entry tenant association
|
||||||
|
|
||||||
|
*For any* valid `tenant_id` and valid entry data (question, answer, category), calling `add_knowledge_entry(tenant_id=X, ...)` should result in the newly created `KnowledgeEntry` record having `tenant_id == X`. If `tenant_id` is not provided, it should default to the configured `get_config().server.tenant_id`.
|
||||||
|
|
||||||
|
**Validates: Requirements 5.2**
|
||||||
|
|
||||||
|
### Property 6: Search results scoped to tenant
|
||||||
|
|
||||||
|
*For any* search query and `tenant_id`, all results returned by `search_knowledge(query=Q, tenant_id=X)` should have `tenant_id == X`. The result set should be a subset of what `search_knowledge(query=Q)` returns (without tenant filter).
|
||||||
|
|
||||||
|
**Validates: Requirements 6.2**
|
||||||
|
|
||||||
|
### Property 7: Stats scoped to tenant
|
||||||
|
|
||||||
|
*For any* `tenant_id`, the statistics returned by `get_knowledge_stats(tenant_id=X)` should reflect only entries with `tenant_id == X`. Specifically, `total_entries` should equal the count of active entries for that tenant, and `average_confidence` should equal the mean confidence of those entries. When `tenant_id` is omitted, the stats should aggregate across all tenants.
|
||||||
|
|
||||||
|
**Validates: Requirements 7.3, 7.4**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### API 层错误处理
|
||||||
|
|
||||||
|
所有 API 端点已使用 `@handle_api_errors` 装饰器,该装饰器捕获以下异常:
|
||||||
|
|
||||||
|
| 异常类型 | HTTP 状态码 | 说明 |
|
||||||
|
|----------|------------|------|
|
||||||
|
| `ValueError` | 400 | 参数校验失败(如 `page < 1`) |
|
||||||
|
| `PermissionError` | 403 | 权限不足 |
|
||||||
|
| `Exception` | 500 | 数据库查询失败等未预期错误 |
|
||||||
|
|
||||||
|
### 业务逻辑层错误处理
|
||||||
|
|
||||||
|
- `get_tenant_summary()` — 数据库异常时返回空列表 `[]`,记录 error 日志。
|
||||||
|
- `get_knowledge_paginated()` — 异常时返回空结构 `{"knowledge": [], "total": 0, ...}`(现有行为保持不变)。
|
||||||
|
- `get_knowledge_stats()` — 异常时返回空字典 `{}`(现有行为保持不变)。
|
||||||
|
- `add_knowledge_entry()` — 异常时返回 `False`,记录 error 日志。
|
||||||
|
|
||||||
|
### 前端错误处理
|
||||||
|
|
||||||
|
- API 请求失败时通过 `showNotification(message, 'error')` 展示错误提示。
|
||||||
|
- 网络超时或断连时显示通用错误消息。
|
||||||
|
- 批量操作部分失败时显示成功/失败计数。
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### 测试框架
|
||||||
|
|
||||||
|
- **单元测试**: `pytest`
|
||||||
|
- **属性测试**: `hypothesis`(Python property-based testing 库)
|
||||||
|
- **每个属性测试最少运行 100 次迭代**
|
||||||
|
|
||||||
|
### 属性测试(Property-Based Tests)
|
||||||
|
|
||||||
|
每个 Correctness Property 对应一个属性测试,使用 `hypothesis` 的 `@given` 装饰器生成随机输入。
|
||||||
|
|
||||||
|
测试标签格式: `Feature: knowledge-tenant-view, Property {number}: {property_text}`
|
||||||
|
|
||||||
|
| Property | 测试描述 | 生成策略 |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| Property 1 | 生成随机 KnowledgeEntry 列表(混合 tenant_id、is_active),验证 `get_tenant_summary()` 聚合正确性 | `st.lists(st.builds(KnowledgeEntry, tenant_id=st.sampled_from([...]), is_active=st.booleans()))` |
|
||||||
|
| Property 2 | 验证 `get_tenant_summary()` 返回列表按 entry_count 降序 | 复用 Property 1 的生成策略 |
|
||||||
|
| Property 3 | 生成随机 tenant_id + category + verified 组合,验证过滤结果一致性 | `st.sampled_from(tenant_ids)`, `st.sampled_from(categories)`, `st.sampled_from(['true','false',''])` |
|
||||||
|
| Property 4 | 生成随机 page/per_page,验证分页切片正确性 | `st.integers(min_value=1, max_value=10)` for page/per_page |
|
||||||
|
| Property 5 | 生成随机 tenant_id 和条目数据,验证新建条目的 tenant_id | `st.text(min_size=1, max_size=50)` for tenant_id |
|
||||||
|
| Property 6 | 生成随机搜索词和 tenant_id,验证搜索结果范围 | `st.text()` for query, `st.sampled_from(tenant_ids)` |
|
||||||
|
| Property 7 | 生成随机 tenant_id,验证统计数据与手动聚合一致 | `st.sampled_from(tenant_ids)` + `st.none()` |
|
||||||
|
|
||||||
|
### 单元测试(Unit Tests)
|
||||||
|
|
||||||
|
单元测试聚焦于边界情况和具体示例:
|
||||||
|
|
||||||
|
- **边界**: 无活跃条目时 `get_tenant_summary()` 返回空数组
|
||||||
|
- **边界**: 不存在的 `tenant_id` 查询返回空列表 + `total=0`
|
||||||
|
- **示例**: 数据库异常时 API 返回 500
|
||||||
|
- **示例**: `add_knowledge_entry` 不传 `tenant_id` 时使用配置默认值
|
||||||
|
- **集成**: 前端 `loadTenantList()` → API → Manager 完整链路
|
||||||
|
|
||||||
|
### 测试配置
|
||||||
|
|
||||||
|
```python
|
||||||
|
from hypothesis import settings
|
||||||
|
|
||||||
|
@settings(max_examples=100)
|
||||||
|
```
|
||||||
|
|
||||||
|
每个属性测试函数头部添加注释引用设计文档中的 Property 编号,例如:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Feature: knowledge-tenant-view, Property 1: Tenant summary correctly aggregates active entries
|
||||||
|
@given(entries=st.lists(knowledge_entry_strategy(), min_size=0, max_size=50))
|
||||||
|
def test_tenant_summary_aggregation(entries):
|
||||||
|
...
|
||||||
|
```
|
||||||
102
.kiro/specs/knowledge-tenant-view/requirements.md
Normal file
102
.kiro/specs/knowledge-tenant-view/requirements.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
知识库租户分组展示功能。当前知识库管理页面以扁平列表展示所有知识条目,缺乏租户(市场)维度的组织结构。本功能将知识库页面改造为两层结构:第一层按租户分组展示汇总信息,第二层展示某个租户下的具体知识条目。数据库模型 `KnowledgeEntry` 已有 `tenant_id` 字段,后端需新增按租户聚合的 API,前端需实现分组视图与钻取交互。
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Dashboard**: Flask + Jinja2 + Bootstrap 5 构建的 Web 管理后台主页面(`dashboard.html`)
|
||||||
|
- **Knowledge_Tab**: Dashboard 中 `#knowledge-tab` 区域,用于展示和管理知识库条目
|
||||||
|
- **Knowledge_API**: Flask Blueprint `knowledge_bp`,提供知识库相关的 REST API(`/api/knowledge/*`)
|
||||||
|
- **Knowledge_Manager**: `KnowledgeManager` 类,封装知识库的数据库查询与业务逻辑
|
||||||
|
- **Tenant**: 租户,即市场标识(如 `market_a`、`market_b`),通过 `KnowledgeEntry.tenant_id` 字段区分
|
||||||
|
- **Tenant_Summary**: 租户汇总信息,包含租户 ID、知识条目总数等聚合数据
|
||||||
|
- **Tenant_List_View**: 第一层视图,以卡片或列表形式展示所有租户的汇总信息
|
||||||
|
- **Tenant_Detail_View**: 第二层视图,展示某个租户下的具体知识条目列表(含分页、筛选)
|
||||||
|
- **KnowledgeEntry**: SQLAlchemy 数据模型,包含 `tenant_id`、`question`、`answer`、`category`、`confidence_score`、`usage_count`、`is_verified` 等字段
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: 租户汇总 API
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望后端提供按租户分组的知识库汇总接口,以便前端展示每个租户的知识条目统计。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a GET request is sent to `/api/knowledge/tenants`, THE Knowledge_API SHALL return a JSON array of Tenant_Summary objects, each containing `tenant_id`, `entry_count`, `verified_count`, and `category_distribution`
|
||||||
|
2. THE Knowledge_API SHALL only count active knowledge entries (`is_active == True`) in the Tenant_Summary aggregation
|
||||||
|
3. THE Knowledge_API SHALL sort the Tenant_Summary array by `entry_count` in descending order
|
||||||
|
4. WHEN no active knowledge entries exist, THE Knowledge_API SHALL return an empty JSON array with HTTP status 200
|
||||||
|
5. IF a database query error occurs, THEN THE Knowledge_API SHALL return an error response with HTTP status 500 and a descriptive error message
|
||||||
|
|
||||||
|
### Requirement 2: 租户条目列表 API
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望后端提供按租户筛选的知识条目分页接口,以便在点击某个租户后查看该租户下的具体知识条目。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a GET request with query parameter `tenant_id` is sent to `/api/knowledge`, THE Knowledge_API SHALL return only the knowledge entries belonging to the specified Tenant
|
||||||
|
2. THE Knowledge_API SHALL support pagination via `page` and `per_page` query parameters when filtering by `tenant_id`
|
||||||
|
3. THE Knowledge_API SHALL support `category` and `verified` query parameters for further filtering within a Tenant
|
||||||
|
4. WHEN the `tenant_id` parameter value does not match any existing entries, THE Knowledge_API SHALL return an empty knowledge list with `total` equal to 0 and HTTP status 200
|
||||||
|
5. THE Knowledge_Manager SHALL provide a method that accepts `tenant_id` as a filter parameter and returns paginated results
|
||||||
|
|
||||||
|
### Requirement 3: 租户列表视图(第一层)
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望知识库页面首先展示按租户分组的汇总卡片,以便快速了解各市场的知识库规模。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the Knowledge_Tab is activated, THE Dashboard SHALL display a Tenant_List_View showing one card per Tenant
|
||||||
|
2. THE Tenant_List_View SHALL display the following information for each Tenant: tenant_id(租户名称), entry_count(知识条目总数), verified_count(已验证条目数)
|
||||||
|
3. WHEN the Tenant_List_View is loading data, THE Dashboard SHALL display a loading spinner in the Knowledge_Tab area
|
||||||
|
4. WHEN no tenants exist, THE Dashboard SHALL display a placeholder message indicating that no knowledge entries are available
|
||||||
|
5. THE Tenant_List_View SHALL refresh its data when the user clicks a refresh button
|
||||||
|
|
||||||
|
### Requirement 4: 租户详情视图(第二层)
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望点击某个租户卡片后能查看该租户下的具体知识条目列表,以便管理和审核知识内容。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user clicks on a Tenant card in the Tenant_List_View, THE Dashboard SHALL transition to the Tenant_Detail_View showing knowledge entries for the selected Tenant
|
||||||
|
2. THE Tenant_Detail_View SHALL display each knowledge entry with the following fields: question, answer, category, confidence_score, usage_count, is_verified status
|
||||||
|
3. THE Tenant_Detail_View SHALL provide a breadcrumb navigation showing "知识库 > {tenant_id}" to indicate the current context
|
||||||
|
4. WHEN the user clicks the breadcrumb "知识库" link, THE Dashboard SHALL navigate back to the Tenant_List_View
|
||||||
|
5. THE Tenant_Detail_View SHALL support pagination with configurable page size
|
||||||
|
6. THE Tenant_Detail_View SHALL support filtering by category and verification status
|
||||||
|
|
||||||
|
### Requirement 5: 租户详情视图中的知识条目操作
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望在租户详情视图中能对知识条目执行添加、删除、验证等操作,以便维护知识库内容。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHILE viewing the Tenant_Detail_View, THE Dashboard SHALL provide buttons for adding, deleting, verifying, and unverifying knowledge entries
|
||||||
|
2. WHEN a user adds a new knowledge entry in the Tenant_Detail_View, THE Knowledge_API SHALL associate the new entry with the currently selected Tenant by setting the `tenant_id` field
|
||||||
|
3. WHEN a user performs a batch operation (batch delete, batch verify, batch unverify) in the Tenant_Detail_View, THE Dashboard SHALL refresh the Tenant_Detail_View to reflect the updated data
|
||||||
|
4. WHEN a user deletes all entries for a Tenant, THE Dashboard SHALL navigate back to the Tenant_List_View and remove the empty Tenant card
|
||||||
|
5. IF a knowledge entry operation fails, THEN THE Dashboard SHALL display an error notification with the failure reason
|
||||||
|
|
||||||
|
### Requirement 6: 搜索功能适配
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望在租户详情视图中搜索知识条目时,搜索范围限定在当前租户内,以便精确查找。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHILE viewing the Tenant_Detail_View, THE Dashboard SHALL scope the knowledge search to the currently selected Tenant
|
||||||
|
2. WHEN a search query is submitted in the Tenant_Detail_View, THE Knowledge_API SHALL filter search results by the specified `tenant_id`
|
||||||
|
3. WHEN the search query is cleared, THE Dashboard SHALL restore the full paginated list for the current Tenant
|
||||||
|
4. THE Knowledge_Manager search method SHALL accept an optional `tenant_id` parameter to limit search scope
|
||||||
|
|
||||||
|
### Requirement 7: 统计信息适配
|
||||||
|
|
||||||
|
**User Story:** 作为管理员,我希望知识库统计面板在租户列表视图时展示全局统计,在租户详情视图时展示当前租户的统计,以便获取准确的上下文信息。
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHILE the Tenant_List_View is displayed, THE Dashboard SHALL show global knowledge statistics (total entries across all tenants, total verified entries, average confidence)
|
||||||
|
2. WHILE the Tenant_Detail_View is displayed, THE Dashboard SHALL show statistics scoped to the selected Tenant
|
||||||
|
3. WHEN a GET request with query parameter `tenant_id` is sent to `/api/knowledge/stats`, THE Knowledge_API SHALL return statistics filtered by the specified Tenant
|
||||||
|
4. WHEN the `tenant_id` parameter is omitted from the stats request, THE Knowledge_API SHALL return global statistics across all tenants
|
||||||
157
.kiro/specs/knowledge-tenant-view/tasks.md
Normal file
157
.kiro/specs/knowledge-tenant-view/tasks.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Implementation Plan: 知识库租户分组展示 (knowledge-tenant-view)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
将知识库管理页面从扁平列表改造为两层结构:第一层按租户分组展示汇总卡片,第二层展示租户下的知识条目列表。改造涉及 KnowledgeManager 业务逻辑层、Flask API 层、前端 dashboard.js 三个层面。
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. KnowledgeManager 新增 get_tenant_summary 方法
|
||||||
|
- [x] 1.1 在 `src/knowledge_base/knowledge_manager.py` 中新增 `get_tenant_summary()` 方法
|
||||||
|
- 使用 SQLAlchemy `GROUP BY tenant_id` 聚合 `is_active == True` 的知识条目
|
||||||
|
- 返回包含 `tenant_id`、`entry_count`、`verified_count`、`category_distribution` 的字典列表
|
||||||
|
- 按 `entry_count` 降序排列
|
||||||
|
- 数据库异常时返回空列表 `[]`,记录 error 日志
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||||
|
|
||||||
|
- [ ]* 1.2 为 get_tenant_summary 编写属性测试
|
||||||
|
- **Property 1: Tenant summary correctly aggregates active entries**
|
||||||
|
- **Property 2: Tenant summary sorted by entry_count descending**
|
||||||
|
- 使用 `hypothesis` 生成随机 KnowledgeEntry 列表,验证聚合正确性和排序
|
||||||
|
- **Validates: Requirements 1.1, 1.2, 1.3**
|
||||||
|
|
||||||
|
- [x] 2. KnowledgeManager 现有方法增加 tenant_id 过滤
|
||||||
|
- [x] 2.1 为 `get_knowledge_paginated()` 增加 `tenant_id` 可选参数
|
||||||
|
- 在 `src/knowledge_base/knowledge_manager.py` 中修改方法签名,增加 `tenant_id: Optional[str] = None`
|
||||||
|
- 当 `tenant_id` 不为 None 时,在查询中增加 `KnowledgeEntry.tenant_id == tenant_id` 过滤条件
|
||||||
|
- 返回结构不变,仅过滤范围缩小
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
|
||||||
|
|
||||||
|
- [ ]* 2.2 为 get_knowledge_paginated 的 tenant_id 过滤编写属性测试
|
||||||
|
- **Property 3: Knowledge entry filtering by tenant, category, and verified status**
|
||||||
|
- **Property 4: Pagination consistency with tenant filter**
|
||||||
|
- **Validates: Requirements 2.1, 2.2, 2.3**
|
||||||
|
|
||||||
|
- [x] 2.3 为 `search_knowledge()` 增加 `tenant_id` 可选参数
|
||||||
|
- 修改 `search_knowledge()`、`_search_by_embedding()`、`_search_by_keyword()` 方法签名
|
||||||
|
- 当 `tenant_id` 不为 None 时,在查询中增加 tenant_id 过滤条件
|
||||||
|
- _Requirements: 6.2, 6.4_
|
||||||
|
|
||||||
|
- [ ]* 2.4 为 search_knowledge 的 tenant_id 过滤编写属性测试
|
||||||
|
- **Property 6: Search results scoped to tenant**
|
||||||
|
- **Validates: Requirements 6.2**
|
||||||
|
|
||||||
|
- [x] 2.5 为 `get_knowledge_stats()` 增加 `tenant_id` 可选参数
|
||||||
|
- 当 `tenant_id` 不为 None 时,所有统计查询增加 tenant_id 过滤
|
||||||
|
- 返回结构中增加 `tenant_id` 字段(仅当按租户筛选时)
|
||||||
|
- _Requirements: 7.3, 7.4_
|
||||||
|
|
||||||
|
- [ ]* 2.6 为 get_knowledge_stats 的 tenant_id 过滤编写属性测试
|
||||||
|
- **Property 7: Stats scoped to tenant**
|
||||||
|
- **Validates: Requirements 7.3, 7.4**
|
||||||
|
|
||||||
|
- [x] 2.7 为 `add_knowledge_entry()` 增加 `tenant_id` 可选参数
|
||||||
|
- 当 `tenant_id` 不为 None 时,新建条目的 `tenant_id` 设为该值
|
||||||
|
- 当 `tenant_id` 为 None 时,使用 `get_config().server.tenant_id` 作为默认值
|
||||||
|
- _Requirements: 5.2_
|
||||||
|
|
||||||
|
- [ ]* 2.8 为 add_knowledge_entry 的 tenant_id 关联编写属性测试
|
||||||
|
- **Property 5: New entry tenant association**
|
||||||
|
- **Validates: Requirements 5.2**
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint - 确保后端业务逻辑层完成
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 4. Knowledge API 层新增和修改端点
|
||||||
|
- [x] 4.1 在 `src/web/blueprints/knowledge.py` 中新增 `GET /api/knowledge/tenants` 端点
|
||||||
|
- 调用 `knowledge_manager.get_tenant_summary()` 返回租户汇总 JSON 数组
|
||||||
|
- 使用 `@handle_api_errors` 装饰器处理异常
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||||
|
|
||||||
|
- [x] 4.2 修改 `GET /api/knowledge` 端点,增加 `tenant_id` 查询参数支持
|
||||||
|
- 从 `request.args` 获取 `tenant_id` 参数,传递给 `get_knowledge_paginated()`
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||||
|
|
||||||
|
- [x] 4.3 修改 `GET /api/knowledge/stats` 端点,增加 `tenant_id` 查询参数支持
|
||||||
|
- 从 `request.args` 获取 `tenant_id` 参数,传递给 `get_knowledge_stats()`
|
||||||
|
- _Requirements: 7.3, 7.4_
|
||||||
|
|
||||||
|
- [x] 4.4 修改 `GET /api/knowledge/search` 端点,增加 `tenant_id` 查询参数支持
|
||||||
|
- 从 `request.args` 获取 `tenant_id` 参数,传递给 `search_knowledge()`
|
||||||
|
- _Requirements: 6.2_
|
||||||
|
|
||||||
|
- [x] 4.5 修改 `POST /api/knowledge` 端点,从请求体读取 `tenant_id` 字段
|
||||||
|
- 将 `tenant_id` 传递给 `add_knowledge_entry()`
|
||||||
|
- _Requirements: 5.2_
|
||||||
|
|
||||||
|
- [ ]* 4.6 为新增和修改的 API 端点编写单元测试
|
||||||
|
- 测试 `/api/knowledge/tenants` 返回正确的汇总数据
|
||||||
|
- 测试各端点的 `tenant_id` 参数过滤行为
|
||||||
|
- 测试空数据和异常情况
|
||||||
|
- _Requirements: 1.1, 1.4, 1.5, 2.4_
|
||||||
|
|
||||||
|
- [x] 5. Checkpoint - 确保后端 API 层完成
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 6. 前端 Tenant_List_View(租户列表视图)
|
||||||
|
- [x] 6.1 在 `src/web/static/js/dashboard.js` 中实现 `loadTenantList()` 函数
|
||||||
|
- 请求 `GET /api/knowledge/tenants` 获取租户汇总数据
|
||||||
|
- 渲染租户卡片列表,每张卡片展示 `tenant_id`、`entry_count`、`verified_count`
|
||||||
|
- 添加加载中 spinner 状态
|
||||||
|
- 无租户时展示空状态占位提示
|
||||||
|
- 卡片点击事件绑定,调用 `loadTenantDetail(tenantId)`
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4_
|
||||||
|
|
||||||
|
- [x] 6.2 实现刷新按钮功能
|
||||||
|
- 在知识库 tab 区域添加刷新按钮,点击时重新调用 `loadTenantList()`
|
||||||
|
- _Requirements: 3.5_
|
||||||
|
|
||||||
|
- [x] 7. 前端 Tenant_Detail_View(租户详情视图)
|
||||||
|
- [x] 7.1 实现 `loadTenantDetail(tenantId, page)` 函数
|
||||||
|
- 请求 `GET /api/knowledge?tenant_id=X&page=P&per_page=N` 获取知识条目
|
||||||
|
- 渲染知识条目表格,展示 question、answer、category、confidence_score、usage_count、is_verified
|
||||||
|
- 实现分页控件
|
||||||
|
- 支持 category 和 verified 筛选下拉框
|
||||||
|
- _Requirements: 4.1, 4.2, 4.5, 4.6_
|
||||||
|
|
||||||
|
- [x] 7.2 实现面包屑导航 `renderBreadcrumb(tenantId)`
|
||||||
|
- 展示 "知识库 > {tenant_id}" 面包屑
|
||||||
|
- 点击 "知识库" 链接时调用 `loadTenantList()` 返回租户列表视图
|
||||||
|
- 管理 `currentTenantId` 状态变量控制视图层级
|
||||||
|
- _Requirements: 4.3, 4.4_
|
||||||
|
|
||||||
|
- [x] 7.3 在 Tenant_Detail_View 中集成知识条目操作按钮
|
||||||
|
- 复用现有的添加、删除、验证、取消验证按钮逻辑
|
||||||
|
- 添加知识条目时自动设置 `tenant_id` 为当前选中的租户
|
||||||
|
- 批量操作(批量删除、批量验证、批量取消验证)后刷新当前视图
|
||||||
|
- 删除所有条目后自动返回租户列表视图
|
||||||
|
- 操作失败时通过 `showNotification` 展示错误提示
|
||||||
|
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
|
||||||
|
|
||||||
|
- [x] 8. 前端搜索和统计面板适配
|
||||||
|
- [x] 8.1 修改搜索功能,在 Tenant_Detail_View 中自动附加 `tenant_id` 参数
|
||||||
|
- 搜索请求附加 `&tenant_id=currentTenantId`
|
||||||
|
- 清空搜索时恢复当前租户的完整分页列表
|
||||||
|
- _Requirements: 6.1, 6.2, 6.3_
|
||||||
|
|
||||||
|
- [x] 8.2 修改 `loadKnowledgeStats()` 函数,根据视图层级请求不同统计
|
||||||
|
- 当 `currentTenantId` 为 null 时请求全局统计
|
||||||
|
- 当 `currentTenantId` 有值时请求 `GET /api/knowledge/stats?tenant_id=X`
|
||||||
|
- _Requirements: 7.1, 7.2_
|
||||||
|
|
||||||
|
- [x] 9. 前端 HTML 模板更新
|
||||||
|
- [x] 9.1 在 `src/web/templates/dashboard.html` 的 `#knowledge-tab` 区域添加必要的 DOM 容器
|
||||||
|
- 添加面包屑容器、租户卡片列表容器、租户详情容器
|
||||||
|
- 确保与现有 Bootstrap 5 样式一致
|
||||||
|
- _Requirements: 3.1, 4.3_
|
||||||
|
|
||||||
|
- [x] 10. Final checkpoint - 确保所有功能集成完成
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation
|
||||||
|
- Property tests validate universal correctness properties from the design document
|
||||||
|
- 数据模型 `KnowledgeEntry` 已有 `tenant_id` 字段且已建索引,无需数据库迁移
|
||||||
Binary file not shown.
@@ -199,6 +199,7 @@ class DatabaseInitializer:
|
|||||||
self._migrate_analytics_enhancements,
|
self._migrate_analytics_enhancements,
|
||||||
self._migrate_system_optimization_fields,
|
self._migrate_system_optimization_fields,
|
||||||
self._migrate_chat_sessions_table,
|
self._migrate_chat_sessions_table,
|
||||||
|
self._migrate_tenant_id_fields,
|
||||||
]
|
]
|
||||||
|
|
||||||
success_count = 0
|
success_count = 0
|
||||||
@@ -477,6 +478,30 @@ class DatabaseInitializer:
|
|||||||
print(f" 会话管理表迁移失败: {e}")
|
print(f" 会话管理表迁移失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _migrate_tenant_id_fields(self) -> bool:
|
||||||
|
"""迁移:为核心表添加 tenant_id 多租户字段"""
|
||||||
|
print(" 检查多租户 tenant_id 字段...")
|
||||||
|
tables = [
|
||||||
|
"work_orders", "chat_sessions", "conversations",
|
||||||
|
"knowledge_entries", "analytics", "alerts", "users",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
added = 0
|
||||||
|
for table in tables:
|
||||||
|
if not self._column_exists(table, 'tenant_id'):
|
||||||
|
print(f" 添加 {table}.tenant_id ...")
|
||||||
|
self._add_table_columns(table, [
|
||||||
|
('tenant_id', "VARCHAR(50) DEFAULT 'default' NOT NULL")
|
||||||
|
])
|
||||||
|
added += 1
|
||||||
|
else:
|
||||||
|
print(f" {table}.tenant_id 已存在")
|
||||||
|
print(f" tenant_id 迁移完成,新增 {added} 个表")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" tenant_id 迁移失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def _add_table_columns(self, table_name: str, fields: List[tuple]) -> bool:
|
def _add_table_columns(self, table_name: str, fields: List[tuple]) -> bool:
|
||||||
"""为表添加字段"""
|
"""为表添加字段"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
13
nginx.conf
13
nginx.conf
@@ -57,6 +57,19 @@ http {
|
|||||||
proxy_read_timeout 30s;
|
proxy_read_timeout 30s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# SSE 流式接口 — 关闭缓冲,支持逐 token 推送
|
||||||
|
location /api/chat/message/stream {
|
||||||
|
proxy_pass http://tsp_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
chunked_transfer_encoding on;
|
||||||
|
}
|
||||||
|
|
||||||
# WebSocket代理
|
# WebSocket代理
|
||||||
location /ws/ {
|
location /ws/ {
|
||||||
proxy_pass http://tsp_backend;
|
proxy_pass http://tsp_backend;
|
||||||
|
|||||||
58
scripts/migrate_tenant.py
Normal file
58
scripts/migrate_tenant.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
多租户迁移脚本
|
||||||
|
为现有数据表添加 tenant_id 字段,已有数据填充为 'default'
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from src.core.database import db_manager
|
||||||
|
from sqlalchemy import text, inspect
|
||||||
|
|
||||||
|
TABLES_TO_MIGRATE = [
|
||||||
|
"work_orders",
|
||||||
|
"chat_sessions",
|
||||||
|
"conversations",
|
||||||
|
"knowledge_entries",
|
||||||
|
"analytics",
|
||||||
|
"alerts",
|
||||||
|
"users",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
print("=" * 50)
|
||||||
|
print("多租户迁移: 添加 tenant_id 字段")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
with db_manager.get_session() as session:
|
||||||
|
inspector = inspect(session.bind)
|
||||||
|
|
||||||
|
for table in TABLES_TO_MIGRATE:
|
||||||
|
# 检查表是否存在
|
||||||
|
if table not in inspector.get_table_names():
|
||||||
|
print(f" [跳过] 表 {table} 不存在")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查字段是否已存在
|
||||||
|
columns = [col["name"] for col in inspector.get_columns(table)]
|
||||||
|
if "tenant_id" in columns:
|
||||||
|
print(f" [已有] 表 {table} 已包含 tenant_id")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 添加字段
|
||||||
|
print(f" [迁移] 表 {table} 添加 tenant_id ...")
|
||||||
|
session.execute(text(
|
||||||
|
f"ALTER TABLE {table} ADD COLUMN tenant_id VARCHAR(50) DEFAULT 'default' NOT NULL"
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
print(f" [完成] 表 {table} 迁移成功")
|
||||||
|
|
||||||
|
print("\n迁移完成!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -49,6 +49,7 @@ class ServerConfig:
|
|||||||
websocket_port: int = 8765
|
websocket_port: int = 8765
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
tenant_id: str = "default" # 当前实例的租户标识
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FeishuConfig:
|
class FeishuConfig:
|
||||||
@@ -145,7 +146,8 @@ class UnifiedConfig:
|
|||||||
port=int(os.getenv("SERVER_PORT", 5000)),
|
port=int(os.getenv("SERVER_PORT", 5000)),
|
||||||
websocket_port=int(os.getenv("WEBSOCKET_PORT", 8765)),
|
websocket_port=int(os.getenv("WEBSOCKET_PORT", 8765)),
|
||||||
debug=os.getenv("DEBUG_MODE", "False").lower() in ('true', '1', 't'),
|
debug=os.getenv("DEBUG_MODE", "False").lower() in ('true', '1', 't'),
|
||||||
log_level=os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
||||||
|
tenant_id=os.getenv("TENANT_ID", "default"),
|
||||||
)
|
)
|
||||||
logger.info("Server config loaded.")
|
logger.info("Server config loaded.")
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -1,35 +1,50 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
统一 LLM 客户端
|
||||||
|
兼容所有 OpenAI 格式 API(千问、Gemini、DeepSeek、本地 Ollama 等)
|
||||||
|
通过 .env 中 LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL 切换模型
|
||||||
|
"""
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any, Generator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from src.config.unified_config import get_config
|
from src.config.unified_config import get_config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class QwenClient:
|
|
||||||
"""阿里云千问API客户端"""
|
|
||||||
|
|
||||||
def __init__(self):
|
class LLMClient:
|
||||||
|
"""
|
||||||
|
统一大模型客户端
|
||||||
|
所有 OpenAI 兼容 API 都走这一个类,不再区分 provider。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str = None, api_key: str = None,
|
||||||
|
model: str = None, timeout: int = None):
|
||||||
config = get_config()
|
config = get_config()
|
||||||
self.base_url = config.llm.base_url or "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
self.base_url = (base_url or config.llm.base_url or
|
||||||
self.api_key = config.llm.api_key
|
"https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||||
self.model_name = config.llm.model
|
self.api_key = api_key or config.llm.api_key
|
||||||
self.timeout = config.llm.timeout
|
self.model_name = model or config.llm.model
|
||||||
|
self.timeout = timeout or config.llm.timeout
|
||||||
self.headers = {
|
self.headers = {
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── 普通请求 ──────────────────────────────────────────
|
||||||
|
|
||||||
def chat_completion(
|
def chat_completion(
|
||||||
self,
|
self,
|
||||||
messages: List[Dict[str, str]],
|
messages: List[Dict[str, str]],
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
max_tokens: int = 1000,
|
max_tokens: int = 1000,
|
||||||
stream: bool = False
|
**kwargs,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""发送聊天请求"""
|
"""标准聊天补全(非流式)"""
|
||||||
try:
|
try:
|
||||||
url = f"{self.base_url}/chat/completions"
|
url = f"{self.base_url}/chat/completions"
|
||||||
payload = {
|
payload = {
|
||||||
@@ -37,114 +52,146 @@ class QwenClient:
|
|||||||
"messages": messages,
|
"messages": messages,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
"stream": stream
|
"stream": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url,
|
url, headers=self.headers, json=payload, timeout=self.timeout
|
||||||
headers=self.headers,
|
|
||||||
json=payload,
|
|
||||||
timeout=self.timeout
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
result = response.json()
|
return response.json()
|
||||||
logger.info("API请求成功")
|
|
||||||
return result
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"API请求失败: {response.status_code} - {response.text}")
|
logger.error(f"LLM API 失败: {response.status_code} - {response.text}")
|
||||||
return {"error": f"API请求失败: {response.status_code}"}
|
return {"error": f"API请求失败: {response.status_code}"}
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
logger.error("API请求超时")
|
logger.error("LLM API 超时")
|
||||||
return {"error": "请求超时"}
|
return {"error": "请求超时"}
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error(f"API请求异常: {e}")
|
logger.error(f"LLM API 异常: {e}")
|
||||||
return {"error": f"请求异常: {str(e)}"}
|
return {"error": f"请求异常: {str(e)}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"未知错误: {e}")
|
logger.error(f"LLM 未知错误: {e}")
|
||||||
return {"error": f"未知错误: {str(e)}"}
|
return {"error": f"未知错误: {str(e)}"}
|
||||||
|
|
||||||
|
# ── 流式请求 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def chat_completion_stream(
|
||||||
|
self,
|
||||||
|
messages: List[Dict[str, str]],
|
||||||
|
temperature: float = 0.7,
|
||||||
|
max_tokens: int = 1000,
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
|
"""流式聊天补全,逐 token yield 文本片段"""
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/chat/completions"
|
||||||
|
payload = {
|
||||||
|
"model": self.model_name,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"stream": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
url, headers=self.headers, json=payload,
|
||||||
|
timeout=self.timeout, stream=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"流式 API 失败: {response.status_code}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for line in response.iter_lines(decode_unicode=True):
|
||||||
|
if not line or not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
data_str = line[6:]
|
||||||
|
if data_str.strip() == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||||
|
content = delta.get("content", "")
|
||||||
|
if content:
|
||||||
|
yield content
|
||||||
|
except (json.JSONDecodeError, IndexError, KeyError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error("流式 API 超时")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"流式 API 异常: {e}")
|
||||||
|
|
||||||
|
# ── 便捷方法 ──────────────────────────────────────────
|
||||||
|
|
||||||
def generate_response(
|
def generate_response(
|
||||||
self,
|
self,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
context: Optional[str] = None,
|
context: Optional[str] = None,
|
||||||
knowledge_base: Optional[List[str]] = None
|
knowledge_base: Optional[List[str]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""生成回复"""
|
"""快捷生成回复"""
|
||||||
messages = []
|
system_prompt = "你是一个专业的客服助手,请根据用户问题提供准确、有帮助的回复。"
|
||||||
|
|
||||||
# 系统提示词
|
|
||||||
system_prompt = "你是一个专业的客服助手,请根据用户问题提供准确、 helpful的回复。"
|
|
||||||
if context:
|
if context:
|
||||||
system_prompt += f"\n\n上下文信息: {context}"
|
system_prompt += f"\n\n上下文信息: {context}"
|
||||||
if knowledge_base:
|
if knowledge_base:
|
||||||
system_prompt += f"\n\n相关知识库: {' '.join(knowledge_base)}"
|
system_prompt += f"\n\n相关知识库: {' '.join(knowledge_base)}"
|
||||||
|
|
||||||
messages.append({"role": "system", "content": system_prompt})
|
messages = [
|
||||||
messages.append({"role": "user", "content": user_message})
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_message},
|
||||||
|
]
|
||||||
|
|
||||||
result = self.chat_completion(messages)
|
result = self.chat_completion(messages)
|
||||||
|
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response_content = result["choices"][0]["message"]["content"]
|
|
||||||
return {
|
return {
|
||||||
"response": response_content,
|
"response": result["choices"][0]["message"]["content"],
|
||||||
"usage": result.get("usage", {}),
|
"usage": result.get("usage", {}),
|
||||||
"model": result.get("model", ""),
|
"model": result.get("model", ""),
|
||||||
"timestamp": datetime.now().isoformat()
|
"timestamp": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
except (KeyError, IndexError) as e:
|
except (KeyError, IndexError) as e:
|
||||||
logger.error(f"解析API响应失败: {e}")
|
logger.error(f"解析响应失败: {e}")
|
||||||
return {"error": f"解析响应失败: {str(e)}"}
|
return {"error": f"解析响应失败: {str(e)}"}
|
||||||
|
|
||||||
def extract_entities(self, text: str) -> Dict[str, Any]:
|
def extract_entities(self, text: str) -> Dict[str, Any]:
|
||||||
"""提取文本中的实体信息"""
|
"""提取文本中的实体信息"""
|
||||||
prompt = f"""
|
import re
|
||||||
请从以下文本中提取关键信息,包括:
|
prompt = (
|
||||||
1. 问题类型/类别
|
f"请从以下文本中提取关键信息,包括:\n"
|
||||||
2. 优先级(高/中/低)
|
f"1. 问题类型/类别\n2. 优先级(高/中/低)\n"
|
||||||
3. 关键词
|
f"3. 关键词\n4. 情感倾向(正面/负面/中性)\n\n"
|
||||||
4. 情感倾向(正面/负面/中性)
|
f"文本: {text}\n\n请以JSON格式返回结果。"
|
||||||
|
)
|
||||||
文本: {text}
|
|
||||||
|
|
||||||
请以JSON格式返回结果。
|
|
||||||
"""
|
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": "你是一个信息提取专家,请准确提取文本中的关键信息。"},
|
{"role": "system", "content": "你是一个信息提取专家,请准确提取文本中的关键信息。"},
|
||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt},
|
||||||
]
|
]
|
||||||
|
|
||||||
result = self.chat_completion(messages, temperature=0.3)
|
result = self.chat_completion(messages, temperature=0.3)
|
||||||
|
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response_content = result["choices"][0]["message"]["content"]
|
content = result["choices"][0]["message"]["content"]
|
||||||
# 尝试解析JSON
|
json_match = re.search(r'\{.*\}', content, re.DOTALL)
|
||||||
import re
|
return json.loads(json_match.group()) if json_match else {"raw_response": content}
|
||||||
json_match = re.search(r'\{.*\}', response_content, re.DOTALL)
|
|
||||||
if json_match:
|
|
||||||
return json.loads(json_match.group())
|
|
||||||
else:
|
|
||||||
return {"raw_response": response_content}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"解析实体提取结果失败: {e}")
|
|
||||||
return {"error": f"解析失败: {str(e)}"}
|
return {"error": f"解析失败: {str(e)}"}
|
||||||
|
|
||||||
def test_connection(self) -> bool:
|
def test_connection(self) -> bool:
|
||||||
"""测试API连接"""
|
"""测试连接"""
|
||||||
try:
|
try:
|
||||||
result = self.chat_completion([
|
result = self.chat_completion(
|
||||||
{"role": "user", "content": "你好"}
|
[{"role": "user", "content": "你好"}], max_tokens=10
|
||||||
], max_tokens=10)
|
)
|
||||||
return "error" not in result
|
return "error" not in result
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"API连接测试失败: {e}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 向后兼容别名 ──────────────────────────────────────────
|
||||||
|
# 旧代码中 `from src.core.llm_client import QwenClient` 仍然能用
|
||||||
|
QwenClient = LLMClient
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, Boolean, ForeignKey
|
from sqlalchemy import Column, Integer, String, Text, DateTime, Float, Boolean, ForeignKey, Index
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -6,11 +6,15 @@ import hashlib
|
|||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# 默认租户ID,单租户部署时使用
|
||||||
|
DEFAULT_TENANT = "default"
|
||||||
|
|
||||||
class WorkOrder(Base):
|
class WorkOrder(Base):
|
||||||
"""工单模型"""
|
"""工单模型"""
|
||||||
__tablename__ = "work_orders"
|
__tablename__ = "work_orders"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
|
||||||
order_id = Column(String(50), unique=True, nullable=False)
|
order_id = Column(String(50), unique=True, nullable=False)
|
||||||
title = Column(String(200), nullable=False)
|
title = Column(String(200), nullable=False)
|
||||||
description = Column(Text, nullable=False)
|
description = Column(Text, nullable=False)
|
||||||
@@ -63,6 +67,7 @@ class ChatSession(Base):
|
|||||||
__tablename__ = "chat_sessions"
|
__tablename__ = "chat_sessions"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
|
||||||
session_id = Column(String(100), unique=True, nullable=False) # 唯一会话标识
|
session_id = Column(String(100), unique=True, nullable=False) # 唯一会话标识
|
||||||
user_id = Column(String(100), nullable=True) # 用户标识
|
user_id = Column(String(100), nullable=True) # 用户标识
|
||||||
work_order_id = Column(Integer, ForeignKey("work_orders.id"), nullable=True)
|
work_order_id = Column(Integer, ForeignKey("work_orders.id"), nullable=True)
|
||||||
@@ -100,6 +105,7 @@ class Conversation(Base):
|
|||||||
__tablename__ = "conversations"
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
|
||||||
session_id = Column(String(100), ForeignKey("chat_sessions.session_id"), nullable=True) # 关联会话
|
session_id = Column(String(100), ForeignKey("chat_sessions.session_id"), nullable=True) # 关联会话
|
||||||
work_order_id = Column(Integer, ForeignKey("work_orders.id"))
|
work_order_id = Column(Integer, ForeignKey("work_orders.id"))
|
||||||
user_message = Column(Text, nullable=False)
|
user_message = Column(Text, nullable=False)
|
||||||
@@ -124,6 +130,7 @@ class KnowledgeEntry(Base):
|
|||||||
__tablename__ = "knowledge_entries"
|
__tablename__ = "knowledge_entries"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
|
||||||
question = Column(Text, nullable=False)
|
question = Column(Text, nullable=False)
|
||||||
answer = Column(Text, nullable=False)
|
answer = Column(Text, nullable=False)
|
||||||
category = Column(String(100), nullable=False)
|
category = Column(String(100), nullable=False)
|
||||||
@@ -164,6 +171,7 @@ class Analytics(Base):
|
|||||||
__tablename__ = "analytics"
|
__tablename__ = "analytics"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
|
||||||
date = Column(DateTime, nullable=False)
|
date = Column(DateTime, nullable=False)
|
||||||
total_orders = Column(Integer, default=0)
|
total_orders = Column(Integer, default=0)
|
||||||
resolved_orders = Column(Integer, default=0)
|
resolved_orders = Column(Integer, default=0)
|
||||||
@@ -184,6 +192,7 @@ class Alert(Base):
|
|||||||
__tablename__ = "alerts"
|
__tablename__ = "alerts"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
|
||||||
rule_name = Column(String(100), nullable=False)
|
rule_name = Column(String(100), nullable=False)
|
||||||
alert_type = Column(String(50), nullable=False)
|
alert_type = Column(String(50), nullable=False)
|
||||||
level = Column(String(20), nullable=False) # info, warning, error, critical
|
level = Column(String(20), nullable=False) # info, warning, error, critical
|
||||||
@@ -242,6 +251,7 @@ class User(Base):
|
|||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
|
||||||
username = Column(String(50), unique=True, nullable=False)
|
username = Column(String(50), unique=True, nullable=False)
|
||||||
password_hash = Column(String(128), nullable=False)
|
password_hash = Column(String(128), nullable=False)
|
||||||
email = Column(String(120), unique=True, nullable=True)
|
email = Column(String(120), unique=True, nullable=True)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from ..core.database import db_manager
|
|||||||
from ..core.models import Conversation, WorkOrder, WorkOrderSuggestion, KnowledgeEntry, ChatSession
|
from ..core.models import Conversation, WorkOrder, WorkOrderSuggestion, KnowledgeEntry, ChatSession
|
||||||
from ..core.redis_manager import redis_manager
|
from ..core.redis_manager import redis_manager
|
||||||
from src.config.unified_config import get_config
|
from src.config.unified_config import get_config
|
||||||
from sqlalchemy import and_, or_, desc
|
from sqlalchemy import and_, or_, desc, func, case
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -634,7 +634,8 @@ class ConversationHistoryManager:
|
|||||||
def get_conversation_analytics(
|
def get_conversation_analytics(
|
||||||
self,
|
self,
|
||||||
work_order_id: Optional[int] = None,
|
work_order_id: Optional[int] = None,
|
||||||
days: int = 7
|
days: int = 7,
|
||||||
|
tenant_id: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""获取对话分析数据(包含AI建议统计)"""
|
"""获取对话分析数据(包含AI建议统计)"""
|
||||||
try:
|
try:
|
||||||
@@ -652,6 +653,8 @@ class ConversationHistoryManager:
|
|||||||
conv_query = session.query(Conversation)
|
conv_query = session.query(Conversation)
|
||||||
if work_order_id:
|
if work_order_id:
|
||||||
conv_query = conv_query.filter(Conversation.work_order_id == work_order_id)
|
conv_query = conv_query.filter(Conversation.work_order_id == work_order_id)
|
||||||
|
if tenant_id is not None:
|
||||||
|
conv_query = conv_query.filter(Conversation.tenant_id == tenant_id)
|
||||||
|
|
||||||
conversations = conv_query.filter(
|
conversations = conv_query.filter(
|
||||||
Conversation.timestamp >= cutoff_date
|
Conversation.timestamp >= cutoff_date
|
||||||
@@ -718,6 +721,49 @@ class ConversationHistoryManager:
|
|||||||
logger.error(f"获取对话分析数据失败: {e}")
|
logger.error(f"获取对话分析数据失败: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
# ==================== 租户汇总方法 ====================
|
||||||
|
|
||||||
|
def get_tenant_summary(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
按 tenant_id 聚合 ChatSession,返回租户汇总列表。
|
||||||
|
按 last_active_time 降序排列。
|
||||||
|
数据库异常或无记录时返回空列表。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with db_manager.get_session() as session:
|
||||||
|
results = session.query(
|
||||||
|
ChatSession.tenant_id,
|
||||||
|
func.count(ChatSession.id).label('session_count'),
|
||||||
|
func.coalesce(func.sum(ChatSession.message_count), 0).label('message_count'),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(ChatSession.status == 'active', 1),
|
||||||
|
else_=0
|
||||||
|
)
|
||||||
|
).label('active_session_count'),
|
||||||
|
func.max(ChatSession.updated_at).label('last_active_time')
|
||||||
|
).group_by(
|
||||||
|
ChatSession.tenant_id
|
||||||
|
).order_by(
|
||||||
|
desc('last_active_time')
|
||||||
|
).all()
|
||||||
|
|
||||||
|
summary = []
|
||||||
|
for row in results:
|
||||||
|
summary.append({
|
||||||
|
'tenant_id': row.tenant_id,
|
||||||
|
'session_count': row.session_count,
|
||||||
|
'message_count': int(row.message_count),
|
||||||
|
'active_session_count': int(row.active_session_count),
|
||||||
|
'last_active_time': row.last_active_time.isoformat() if row.last_active_time else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取租户汇总失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
# ==================== 会话管理方法 ====================
|
# ==================== 会话管理方法 ====================
|
||||||
|
|
||||||
def get_sessions_paginated(
|
def get_sessions_paginated(
|
||||||
@@ -726,13 +772,17 @@ class ConversationHistoryManager:
|
|||||||
per_page: int = 20,
|
per_page: int = 20,
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
search: str = '',
|
search: str = '',
|
||||||
date_filter: str = ''
|
date_filter: str = '',
|
||||||
|
tenant_id: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""分页获取会话列表"""
|
"""分页获取会话列表"""
|
||||||
try:
|
try:
|
||||||
with db_manager.get_session() as session:
|
with db_manager.get_session() as session:
|
||||||
query = session.query(ChatSession)
|
query = session.query(ChatSession)
|
||||||
|
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(ChatSession.tenant_id == tenant_id)
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(ChatSession.status == status)
|
query = query.filter(ChatSession.status == status)
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,98 @@ class RealtimeChatManager:
|
|||||||
"ai_suggestions": []
|
"ai_suggestions": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _generate_response_stream(self, user_message: str, knowledge_results: List[Dict], context: List[Dict], work_order_id: Optional[int] = None):
|
||||||
|
"""流式生成回复,yield 每个 token 片段"""
|
||||||
|
try:
|
||||||
|
ai_suggestions = self._get_workorder_ai_suggestions(work_order_id)
|
||||||
|
prompt = self._build_chat_prompt(user_message, knowledge_results, context, ai_suggestions)
|
||||||
|
|
||||||
|
for chunk in self.llm_client.chat_completion_stream(
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=1000,
|
||||||
|
):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"流式生成回复失败: {e}")
|
||||||
|
yield "抱歉,系统出现错误,请稍后再试。"
|
||||||
|
|
||||||
|
def process_message_stream(self, session_id: str, user_message: str, ip_address: str = None, invocation_method: str = "http_stream"):
|
||||||
|
"""流式处理用户消息,yield SSE 事件"""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
if session_id not in self.active_sessions:
|
||||||
|
yield f"data: {json.dumps({'error': '会话不存在'}, ensure_ascii=False)}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
session = self.active_sessions[session_id]
|
||||||
|
session["last_activity"] = datetime.now()
|
||||||
|
session["message_count"] += 1
|
||||||
|
session["ip_address"] = ip_address
|
||||||
|
session["invocation_method"] = invocation_method
|
||||||
|
|
||||||
|
user_msg = ChatMessage(
|
||||||
|
role="user",
|
||||||
|
content=user_message,
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
message_id=f"msg_{int(_time.time())}_{session['message_count']}"
|
||||||
|
)
|
||||||
|
self.message_history[session_id].append(user_msg)
|
||||||
|
|
||||||
|
# 搜索知识 + VIN
|
||||||
|
knowledge_results = self._search_knowledge(user_message)
|
||||||
|
vin = self._extract_vin(user_message)
|
||||||
|
if vin:
|
||||||
|
latest = self.vehicle_manager.get_latest_vehicle_data_by_vin(vin)
|
||||||
|
if latest:
|
||||||
|
knowledge_results = [{
|
||||||
|
"question": f"VIN {vin} 的最新实时数据",
|
||||||
|
"answer": json.dumps(latest, ensure_ascii=False),
|
||||||
|
"similarity_score": 1.0,
|
||||||
|
"source": "vehicle_realtime"
|
||||||
|
}] + knowledge_results
|
||||||
|
knowledge_results = knowledge_results[:5]
|
||||||
|
|
||||||
|
# 流式生成
|
||||||
|
full_content = []
|
||||||
|
for chunk in self._generate_response_stream(
|
||||||
|
user_message, knowledge_results, session["context"], session["work_order_id"]
|
||||||
|
):
|
||||||
|
full_content.append(chunk)
|
||||||
|
yield f"data: {json.dumps({'chunk': chunk}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
# 拼接完整回复
|
||||||
|
content = "".join(full_content)
|
||||||
|
confidence = self._calculate_confidence(knowledge_results, content)
|
||||||
|
|
||||||
|
# 创建助手消息并保存
|
||||||
|
assistant_msg = ChatMessage(
|
||||||
|
role="assistant",
|
||||||
|
content=content,
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
message_id=f"msg_{int(_time.time())}_{session['message_count'] + 1}",
|
||||||
|
work_order_id=session["work_order_id"],
|
||||||
|
knowledge_used=knowledge_results,
|
||||||
|
confidence_score=confidence,
|
||||||
|
)
|
||||||
|
self.message_history[session_id].append(assistant_msg)
|
||||||
|
|
||||||
|
session["context"].append({"role": "user", "content": user_message})
|
||||||
|
session["context"].append({"role": "assistant", "content": content})
|
||||||
|
if len(session["context"]) > 20:
|
||||||
|
session["context"] = session["context"][-20:]
|
||||||
|
|
||||||
|
self._save_conversation(session_id, user_msg, assistant_msg, ip_address, invocation_method)
|
||||||
|
|
||||||
|
if knowledge_results:
|
||||||
|
used_ids = [r["id"] for r in knowledge_results if r.get("id")]
|
||||||
|
if used_ids:
|
||||||
|
self.knowledge_manager.update_usage_count(used_ids)
|
||||||
|
|
||||||
|
# 发送完成事件
|
||||||
|
yield f"data: {json.dumps({'done': True, 'confidence_score': confidence, 'message_id': assistant_msg.message_id}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
def _build_chat_prompt(self, user_message: str, knowledge_results: List[Dict], context: List[Dict], ai_suggestions: List[str] = None) -> str:
|
def _build_chat_prompt(self, user_message: str, knowledge_results: List[Dict], context: List[Dict], ai_suggestions: List[str] = None) -> str:
|
||||||
"""构建聊天提示词"""
|
"""构建聊天提示词"""
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from sklearn.feature_extraction.text import TfidfVectorizer
|
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||||
from sklearn.metrics.pairwise import cosine_similarity
|
from sklearn.metrics.pairwise import cosine_similarity
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, Integer
|
||||||
|
|
||||||
from ..core.database import db_manager
|
from ..core.database import db_manager
|
||||||
from ..core.models import KnowledgeEntry, WorkOrder, Conversation
|
from ..core.models import KnowledgeEntry, WorkOrder, Conversation
|
||||||
@@ -162,24 +162,24 @@ class KnowledgeManager:
|
|||||||
logger.error(f"查找相似条目失败: {e}")
|
logger.error(f"查找相似条目失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def search_knowledge(self, query: str, top_k: int = 3, verified_only: bool = True) -> List[Dict[str, Any]]:
|
def search_knowledge(self, query: str, top_k: int = 3, verified_only: bool = True, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""搜索知识库 — 优先使用 embedding 语义检索,降级为关键词匹配"""
|
"""搜索知识库 — 优先使用 embedding 语义检索,降级为关键词匹配"""
|
||||||
try:
|
try:
|
||||||
# 尝试 embedding 语义检索
|
# 尝试 embedding 语义检索
|
||||||
if self.embedding_enabled:
|
if self.embedding_enabled:
|
||||||
results = self._search_by_embedding(query, top_k, verified_only)
|
results = self._search_by_embedding(query, top_k, verified_only, tenant_id=tenant_id)
|
||||||
if results:
|
if results:
|
||||||
return results
|
return results
|
||||||
logger.debug("Embedding 检索无结果,降级为关键词匹配")
|
logger.debug("Embedding 检索无结果,降级为关键词匹配")
|
||||||
|
|
||||||
# 降级:关键词匹配
|
# 降级:关键词匹配
|
||||||
return self._search_by_keyword(query, top_k, verified_only)
|
return self._search_by_keyword(query, top_k, verified_only, tenant_id=tenant_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"搜索知识库失败: {e}")
|
logger.error(f"搜索知识库失败: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _search_by_embedding(self, query: str, top_k: int = 3, verified_only: bool = True) -> List[Dict[str, Any]]:
|
def _search_by_embedding(self, query: str, top_k: int = 3, verified_only: bool = True, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""基于 embedding 向量的语义检索"""
|
"""基于 embedding 向量的语义检索"""
|
||||||
try:
|
try:
|
||||||
query_vec = self.embedding_client.embed_text(query)
|
query_vec = self.embedding_client.embed_text(query)
|
||||||
@@ -205,6 +205,8 @@ class KnowledgeManager:
|
|||||||
KnowledgeEntry.id.in_(candidate_ids),
|
KnowledgeEntry.id.in_(candidate_ids),
|
||||||
KnowledgeEntry.is_active == True
|
KnowledgeEntry.is_active == True
|
||||||
)
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query_filter = query_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
|
||||||
if verified_only:
|
if verified_only:
|
||||||
query_filter = query_filter.filter(KnowledgeEntry.is_verified == True)
|
query_filter = query_filter.filter(KnowledgeEntry.is_verified == True)
|
||||||
|
|
||||||
@@ -212,10 +214,13 @@ class KnowledgeManager:
|
|||||||
|
|
||||||
# 如果 verified_only 没结果,回退到全部
|
# 如果 verified_only 没结果,回退到全部
|
||||||
if not entries and verified_only:
|
if not entries and verified_only:
|
||||||
entries = session.query(KnowledgeEntry).filter(
|
fallback_filter = session.query(KnowledgeEntry).filter(
|
||||||
KnowledgeEntry.id.in_(candidate_ids),
|
KnowledgeEntry.id.in_(candidate_ids),
|
||||||
KnowledgeEntry.is_active == True
|
KnowledgeEntry.is_active == True
|
||||||
).all()
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
fallback_filter = fallback_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
|
||||||
|
entries = fallback_filter.all()
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -240,7 +245,7 @@ class KnowledgeManager:
|
|||||||
logger.error(f"Embedding 搜索失败: {e}")
|
logger.error(f"Embedding 搜索失败: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _search_by_keyword(self, query: str, top_k: int = 3, verified_only: bool = True) -> List[Dict[str, Any]]:
|
def _search_by_keyword(self, query: str, top_k: int = 3, verified_only: bool = True, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""基于关键词的搜索(降级方案)"""
|
"""基于关键词的搜索(降级方案)"""
|
||||||
try:
|
try:
|
||||||
with db_manager.get_session() as session:
|
with db_manager.get_session() as session:
|
||||||
@@ -249,6 +254,9 @@ class KnowledgeManager:
|
|||||||
KnowledgeEntry.is_active == True
|
KnowledgeEntry.is_active == True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if tenant_id is not None:
|
||||||
|
query_filter = query_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
|
||||||
|
|
||||||
# 如果只搜索已验证的知识库
|
# 如果只搜索已验证的知识库
|
||||||
if verified_only:
|
if verified_only:
|
||||||
query_filter = query_filter.filter(KnowledgeEntry.is_verified == True)
|
query_filter = query_filter.filter(KnowledgeEntry.is_verified == True)
|
||||||
@@ -256,7 +264,10 @@ class KnowledgeManager:
|
|||||||
entries = query_filter.all()
|
entries = query_filter.all()
|
||||||
# 若已验证为空,则回退到全部活跃条目
|
# 若已验证为空,则回退到全部活跃条目
|
||||||
if not entries and verified_only:
|
if not entries and verified_only:
|
||||||
entries = session.query(KnowledgeEntry).filter(KnowledgeEntry.is_active == True).all()
|
fallback_filter = session.query(KnowledgeEntry).filter(KnowledgeEntry.is_active == True)
|
||||||
|
if tenant_id is not None:
|
||||||
|
fallback_filter = fallback_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
|
||||||
|
entries = fallback_filter.all()
|
||||||
|
|
||||||
if not entries:
|
if not entries:
|
||||||
logger.warning("知识库中没有活跃条目")
|
logger.warning("知识库中没有活跃条目")
|
||||||
@@ -334,10 +345,14 @@ class KnowledgeManager:
|
|||||||
answer: str,
|
answer: str,
|
||||||
category: str,
|
category: str,
|
||||||
confidence_score: float = 0.5,
|
confidence_score: float = 0.5,
|
||||||
is_verified: bool = False
|
is_verified: bool = False,
|
||||||
|
tenant_id: Optional[str] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""添加知识库条目"""
|
"""添加知识库条目"""
|
||||||
try:
|
try:
|
||||||
|
# 确定 tenant_id:优先使用传入值,否则取配置默认值
|
||||||
|
effective_tenant_id = tenant_id if tenant_id is not None else get_config().server.tenant_id
|
||||||
|
|
||||||
# 生成 embedding
|
# 生成 embedding
|
||||||
embedding_json = None
|
embedding_json = None
|
||||||
text_for_embedding = question + " " + answer
|
text_for_embedding = question + " " + answer
|
||||||
@@ -354,6 +369,7 @@ class KnowledgeManager:
|
|||||||
confidence_score=confidence_score,
|
confidence_score=confidence_score,
|
||||||
usage_count=0,
|
usage_count=0,
|
||||||
is_verified=is_verified,
|
is_verified=is_verified,
|
||||||
|
tenant_id=effective_tenant_id,
|
||||||
vector_embedding=embedding_json
|
vector_embedding=embedding_json
|
||||||
)
|
)
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
@@ -541,18 +557,23 @@ class KnowledgeManager:
|
|||||||
logger.error(f"删除知识库条目失败: {e}")
|
logger.error(f"删除知识库条目失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_knowledge_stats(self) -> Dict[str, Any]:
|
def get_knowledge_stats(self, tenant_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""获取知识库统计信息"""
|
"""获取知识库统计信息"""
|
||||||
try:
|
try:
|
||||||
with db_manager.get_session() as session:
|
with db_manager.get_session() as session:
|
||||||
|
# 基础过滤条件
|
||||||
|
base_filter = [KnowledgeEntry.is_active == True]
|
||||||
|
if tenant_id is not None:
|
||||||
|
base_filter.append(KnowledgeEntry.tenant_id == tenant_id)
|
||||||
|
|
||||||
# 只统计活跃(未删除)的条目
|
# 只统计活跃(未删除)的条目
|
||||||
total_entries = session.query(KnowledgeEntry).filter(
|
total_entries = session.query(KnowledgeEntry).filter(
|
||||||
KnowledgeEntry.is_active == True
|
*base_filter
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
# 统计已验证的条目
|
# 统计已验证的条目
|
||||||
verified_entries = session.query(KnowledgeEntry).filter(
|
verified_entries = session.query(KnowledgeEntry).filter(
|
||||||
KnowledgeEntry.is_active == True,
|
*base_filter,
|
||||||
KnowledgeEntry.is_verified == True
|
KnowledgeEntry.is_verified == True
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
@@ -561,27 +582,100 @@ class KnowledgeManager:
|
|||||||
KnowledgeEntry.category,
|
KnowledgeEntry.category,
|
||||||
func.count(KnowledgeEntry.id)
|
func.count(KnowledgeEntry.id)
|
||||||
).filter(
|
).filter(
|
||||||
KnowledgeEntry.is_active == True
|
*base_filter
|
||||||
).group_by(KnowledgeEntry.category).all()
|
).group_by(KnowledgeEntry.category).all()
|
||||||
|
|
||||||
# 平均置信度(仅限活跃条目)
|
# 平均置信度(仅限活跃条目)
|
||||||
avg_confidence = session.query(
|
avg_confidence = session.query(
|
||||||
func.avg(KnowledgeEntry.confidence_score)
|
func.avg(KnowledgeEntry.confidence_score)
|
||||||
).filter(
|
).filter(
|
||||||
KnowledgeEntry.is_active == True
|
*base_filter
|
||||||
).scalar() or 0.0
|
).scalar() or 0.0
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"total_entries": total_entries,
|
"total_entries": total_entries,
|
||||||
"active_entries": verified_entries, # 将 active_entries 复用为已验证数量,或前端相应修改
|
"active_entries": verified_entries, # 将 active_entries 复用为已验证数量,或前端相应修改
|
||||||
"category_distribution": dict(category_stats),
|
"category_distribution": dict(category_stats),
|
||||||
"average_confidence": float(avg_confidence)
|
"average_confidence": float(avg_confidence)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tenant_id is not None:
|
||||||
|
result["tenant_id"] = tenant_id
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取知识库统计失败: {e}")
|
logger.error(f"获取知识库统计失败: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
def get_tenant_summary(self) -> List[Dict[str, Any]]:
|
||||||
|
"""按 tenant_id 聚合活跃知识条目,返回租户汇总列表。
|
||||||
|
|
||||||
|
返回格式: [
|
||||||
|
{
|
||||||
|
"tenant_id": "market_a",
|
||||||
|
"entry_count": 42,
|
||||||
|
"verified_count": 30,
|
||||||
|
"category_distribution": {"FAQ": 20, "故障排查": 22}
|
||||||
|
}, ...
|
||||||
|
]
|
||||||
|
按 entry_count 降序排列。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with db_manager.get_session() as session:
|
||||||
|
# 主聚合查询:按 tenant_id 统计 entry_count 和 verified_count
|
||||||
|
summary_rows = session.query(
|
||||||
|
KnowledgeEntry.tenant_id,
|
||||||
|
func.count(KnowledgeEntry.id).label('entry_count'),
|
||||||
|
func.sum(
|
||||||
|
func.cast(KnowledgeEntry.is_verified, Integer)
|
||||||
|
).label('verified_count')
|
||||||
|
).filter(
|
||||||
|
KnowledgeEntry.is_active == True
|
||||||
|
).group_by(
|
||||||
|
KnowledgeEntry.tenant_id
|
||||||
|
).order_by(
|
||||||
|
func.count(KnowledgeEntry.id).desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not summary_rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 类别分布查询:按 tenant_id + category 统计
|
||||||
|
category_rows = session.query(
|
||||||
|
KnowledgeEntry.tenant_id,
|
||||||
|
KnowledgeEntry.category,
|
||||||
|
func.count(KnowledgeEntry.id).label('cat_count')
|
||||||
|
).filter(
|
||||||
|
KnowledgeEntry.is_active == True
|
||||||
|
).group_by(
|
||||||
|
KnowledgeEntry.tenant_id,
|
||||||
|
KnowledgeEntry.category
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 构建 tenant_id -> {category: count} 映射
|
||||||
|
category_map: Dict[str, Dict[str, int]] = {}
|
||||||
|
for row in category_rows:
|
||||||
|
if row.tenant_id not in category_map:
|
||||||
|
category_map[row.tenant_id] = {}
|
||||||
|
category_map[row.tenant_id][row.category] = row.cat_count
|
||||||
|
|
||||||
|
# 组装结果
|
||||||
|
result = []
|
||||||
|
for row in summary_rows:
|
||||||
|
result.append({
|
||||||
|
"tenant_id": row.tenant_id,
|
||||||
|
"entry_count": row.entry_count,
|
||||||
|
"verified_count": int(row.verified_count or 0),
|
||||||
|
"category_distribution": category_map.get(row.tenant_id, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取租户汇总失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def update_usage_count(self, entry_ids: List[int]) -> bool:
|
def update_usage_count(self, entry_ids: List[int]) -> bool:
|
||||||
"""更新知识库条目的使用次数"""
|
"""更新知识库条目的使用次数"""
|
||||||
try:
|
try:
|
||||||
@@ -602,12 +696,15 @@ class KnowledgeManager:
|
|||||||
logger.error(f"更新知识库使用次数失败: {e}")
|
logger.error(f"更新知识库使用次数失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_knowledge_paginated(self, page: int = 1, per_page: int = 10, category_filter: str = '', verified_filter: str = '') -> Dict[str, Any]:
|
def get_knowledge_paginated(self, page: int = 1, per_page: int = 10, category_filter: str = '', verified_filter: str = '', tenant_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""获取知识库条目(分页和过滤)"""
|
"""获取知识库条目(分页和过滤)"""
|
||||||
try:
|
try:
|
||||||
with db_manager.get_session() as session:
|
with db_manager.get_session() as session:
|
||||||
query = session.query(KnowledgeEntry).filter(KnowledgeEntry.is_active == True)
|
query = session.query(KnowledgeEntry).filter(KnowledgeEntry.is_active == True)
|
||||||
|
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(KnowledgeEntry.tenant_id == tenant_id)
|
||||||
|
|
||||||
if category_filter:
|
if category_filter:
|
||||||
query = query.filter(KnowledgeEntry.category == category_filter)
|
query = query.filter(KnowledgeEntry.category == category_filter)
|
||||||
if verified_filter:
|
if verified_filter:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from flask import Flask, render_template, request, jsonify, send_from_directory, make_response, session, redirect, url_for
|
from flask import Flask, render_template, request, jsonify, send_from_directory, make_response, session, redirect, url_for, Response
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
from src.config.unified_config import get_config
|
from src.config.unified_config import get_config
|
||||||
@@ -207,6 +207,33 @@ def send_chat_message():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/chat/message/stream', methods=['POST'])
|
||||||
|
def send_chat_message_stream():
|
||||||
|
"""流式聊天消息 — SSE 逐 token 推送"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
session_id = data.get('session_id')
|
||||||
|
message = data.get('message')
|
||||||
|
|
||||||
|
if not session_id or not message:
|
||||||
|
return jsonify({"error": "缺少必要参数"}), 400
|
||||||
|
|
||||||
|
chat_mgr = service_manager.get_chat_manager()
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
try:
|
||||||
|
for event in chat_mgr.process_message_stream(session_id, message):
|
||||||
|
yield event
|
||||||
|
except Exception as e:
|
||||||
|
import json as _json
|
||||||
|
yield f"data: {_json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
return Response(generate(), mimetype='text/event-stream',
|
||||||
|
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/chat/history/<session_id>')
|
@app.route('/api/chat/history/<session_id>')
|
||||||
def get_chat_history(session_id):
|
def get_chat_history(session_id):
|
||||||
"""获取对话历史"""
|
"""获取对话历史"""
|
||||||
|
|||||||
@@ -335,10 +335,12 @@ def get_conversation_analytics():
|
|||||||
try:
|
try:
|
||||||
work_order_id = request.args.get('work_order_id', type=int)
|
work_order_id = request.args.get('work_order_id', type=int)
|
||||||
days = request.args.get('days', 7, type=int)
|
days = request.args.get('days', 7, type=int)
|
||||||
|
tenant_id = request.args.get('tenant_id')
|
||||||
|
|
||||||
analytics = history_manager.get_conversation_analytics(
|
analytics = history_manager.get_conversation_analytics(
|
||||||
work_order_id=work_order_id,
|
work_order_id=work_order_id,
|
||||||
days=days
|
days=days,
|
||||||
|
tenant_id=tenant_id
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -351,6 +353,17 @@ def get_conversation_analytics():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@conversations_bp.route('/tenants', methods=['GET'])
|
||||||
|
def get_tenants():
|
||||||
|
"""获取租户汇总列表"""
|
||||||
|
try:
|
||||||
|
tenants = history_manager.get_tenant_summary()
|
||||||
|
return jsonify(tenants)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取租户汇总失败: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ==================== 会话管理 API ====================
|
# ==================== 会话管理 API ====================
|
||||||
|
|
||||||
@conversations_bp.route('/sessions')
|
@conversations_bp.route('/sessions')
|
||||||
@@ -362,13 +375,15 @@ def get_sessions():
|
|||||||
status = request.args.get('status', '') # active, ended, 空=全部
|
status = request.args.get('status', '') # active, ended, 空=全部
|
||||||
search = request.args.get('search', '')
|
search = request.args.get('search', '')
|
||||||
date_filter = request.args.get('date_filter', '')
|
date_filter = request.args.get('date_filter', '')
|
||||||
|
tenant_id = request.args.get('tenant_id')
|
||||||
|
|
||||||
result = history_manager.get_sessions_paginated(
|
result = history_manager.get_sessions_paginated(
|
||||||
page=page,
|
page=page,
|
||||||
per_page=per_page,
|
per_page=per_page,
|
||||||
status=status or None,
|
status=status or None,
|
||||||
search=search,
|
search=search,
|
||||||
date_filter=date_filter
|
date_filter=date_filter,
|
||||||
|
tenant_id=tenant_id
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ def get_agent_assistant():
|
|||||||
_agent_assistant = TSPAgentAssistant()
|
_agent_assistant = TSPAgentAssistant()
|
||||||
return _agent_assistant
|
return _agent_assistant
|
||||||
|
|
||||||
|
@knowledge_bp.route('/tenants')
|
||||||
|
@handle_api_errors
|
||||||
|
def get_tenants():
|
||||||
|
"""获取租户汇总列表"""
|
||||||
|
try:
|
||||||
|
result = service_manager.get_assistant().knowledge_manager.get_tenant_summary()
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"获取租户汇总失败: {e}")
|
||||||
|
return create_error_response("获取租户汇总失败", 500)
|
||||||
|
|
||||||
@knowledge_bp.route('')
|
@knowledge_bp.route('')
|
||||||
@handle_api_errors
|
@handle_api_errors
|
||||||
def get_knowledge():
|
def get_knowledge():
|
||||||
@@ -33,12 +45,14 @@ def get_knowledge():
|
|||||||
per_page = request.args.get('per_page', 10, type=int)
|
per_page = request.args.get('per_page', 10, type=int)
|
||||||
category_filter = request.args.get('category', '')
|
category_filter = request.args.get('category', '')
|
||||||
verified_filter = request.args.get('verified', '')
|
verified_filter = request.args.get('verified', '')
|
||||||
|
tenant_id = request.args.get('tenant_id')
|
||||||
|
|
||||||
result = service_manager.get_assistant().knowledge_manager.get_knowledge_paginated(
|
result = service_manager.get_assistant().knowledge_manager.get_knowledge_paginated(
|
||||||
page=page,
|
page=page,
|
||||||
per_page=per_page,
|
per_page=per_page,
|
||||||
category_filter=category_filter,
|
category_filter=category_filter,
|
||||||
verified_filter=verified_filter
|
verified_filter=verified_filter,
|
||||||
|
tenant_id=tenant_id
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
@@ -47,6 +61,7 @@ def get_knowledge():
|
|||||||
def search_knowledge():
|
def search_knowledge():
|
||||||
"""搜索知识库"""
|
"""搜索知识库"""
|
||||||
query = request.args.get('q', '')
|
query = request.args.get('q', '')
|
||||||
|
tenant_id = request.args.get('tenant_id')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info(f"搜索查询: '{query}'")
|
logger.info(f"搜索查询: '{query}'")
|
||||||
|
|
||||||
@@ -55,7 +70,7 @@ def search_knowledge():
|
|||||||
return jsonify([])
|
return jsonify([])
|
||||||
|
|
||||||
assistant = service_manager.get_assistant()
|
assistant = service_manager.get_assistant()
|
||||||
results = assistant.knowledge_manager.search_knowledge(query, top_k=5)
|
results = assistant.knowledge_manager.search_knowledge(query, top_k=5, tenant_id=tenant_id)
|
||||||
logger.info(f"搜索结果数量: {len(results)}")
|
logger.info(f"搜索结果数量: {len(results)}")
|
||||||
return jsonify(results)
|
return jsonify(results)
|
||||||
|
|
||||||
@@ -64,11 +79,13 @@ def search_knowledge():
|
|||||||
def add_knowledge():
|
def add_knowledge():
|
||||||
"""添加知识库条目"""
|
"""添加知识库条目"""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
tenant_id = data.get('tenant_id')
|
||||||
success = service_manager.get_assistant().knowledge_manager.add_knowledge_entry(
|
success = service_manager.get_assistant().knowledge_manager.add_knowledge_entry(
|
||||||
question=data['question'],
|
question=data['question'],
|
||||||
answer=data['answer'],
|
answer=data['answer'],
|
||||||
category=data['category'],
|
category=data['category'],
|
||||||
confidence_score=data.get('confidence_score', 0.8)
|
confidence_score=data.get('confidence_score', 0.8),
|
||||||
|
tenant_id=tenant_id
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
return create_success_response("知识添加成功")
|
return create_success_response("知识添加成功")
|
||||||
@@ -79,7 +96,8 @@ def add_knowledge():
|
|||||||
@handle_api_errors
|
@handle_api_errors
|
||||||
def get_knowledge_stats():
|
def get_knowledge_stats():
|
||||||
"""获取知识库统计"""
|
"""获取知识库统计"""
|
||||||
stats = service_manager.get_assistant().knowledge_manager.get_knowledge_stats()
|
tenant_id = request.args.get('tenant_id')
|
||||||
|
stats = service_manager.get_assistant().knowledge_manager.get_knowledge_stats(tenant_id=tenant_id)
|
||||||
return jsonify(stats)
|
return jsonify(stats)
|
||||||
|
|
||||||
@knowledge_bp.route('/upload', methods=['POST'])
|
@knowledge_bp.route('/upload', methods=['POST'])
|
||||||
|
|||||||
@@ -63,6 +63,42 @@ def get_settings():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@system_bp.route('/runtime-config')
|
||||||
|
def get_runtime_config():
|
||||||
|
"""获取运行时配置信息(不含敏感信息)"""
|
||||||
|
try:
|
||||||
|
from src.config.unified_config import get_config
|
||||||
|
cfg = get_config()
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"tenant_id": cfg.server.tenant_id,
|
||||||
|
"llm": {
|
||||||
|
"provider": cfg.llm.provider,
|
||||||
|
"model": cfg.llm.model,
|
||||||
|
"base_url": cfg.llm.base_url or "",
|
||||||
|
"temperature": cfg.llm.temperature,
|
||||||
|
"max_tokens": cfg.llm.max_tokens,
|
||||||
|
"timeout": cfg.llm.timeout,
|
||||||
|
},
|
||||||
|
"embedding": {
|
||||||
|
"enabled": cfg.embedding.enabled,
|
||||||
|
"model": cfg.embedding.model,
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"enabled": cfg.redis.enabled,
|
||||||
|
"host": cfg.redis.host,
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"port": cfg.server.port,
|
||||||
|
"websocket_port": cfg.server.websocket_port,
|
||||||
|
"debug": cfg.server.debug,
|
||||||
|
"log_level": cfg.server.log_level,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@system_bp.route('/settings', methods=['POST'])
|
@system_bp.route('/settings', methods=['POST'])
|
||||||
def save_settings():
|
def save_settings():
|
||||||
"""保存系统设置"""
|
"""保存系统设置"""
|
||||||
|
|||||||
@@ -104,28 +104,69 @@ class ChatHttpClient {
|
|||||||
this.showTypingIndicator();
|
this.showTypingIndicator();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.sendRequest('POST', '/message', {
|
// 使用流式接口
|
||||||
session_id: this.sessionId,
|
const response = await fetch('/api/chat/message/stream', {
|
||||||
message: message
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: this.sessionId, message: message })
|
||||||
});
|
});
|
||||||
|
|
||||||
this.hideTypingIndicator();
|
this.hideTypingIndicator();
|
||||||
|
|
||||||
if (response.success) {
|
if (!response.ok) {
|
||||||
// 添加助手回复
|
this.addMessage('assistant', '请求失败,请稍后再试。');
|
||||||
this.addMessage('assistant', response.content, {
|
return;
|
||||||
knowledge_used: response.knowledge_used,
|
}
|
||||||
confidence_score: response.confidence_score,
|
|
||||||
work_order_id: response.work_order_id
|
|
||||||
});
|
|
||||||
|
|
||||||
// 更新工单ID
|
// 创建一个空的助手消息容器用于流式填充
|
||||||
if (response.work_order_id) {
|
const msgEl = this.addMessage('assistant', '', {}, true);
|
||||||
document.getElementById('work-order-id').value = response.work_order_id;
|
const contentEl = msgEl.querySelector('.message-content') || msgEl;
|
||||||
|
let fullContent = '';
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop(); // 保留不完整的行
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
const dataStr = line.slice(6).trim();
|
||||||
|
if (dataStr === '[DONE]') continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(dataStr);
|
||||||
|
if (data.chunk) {
|
||||||
|
fullContent += data.chunk;
|
||||||
|
contentEl.textContent = fullContent;
|
||||||
|
// 自动滚动
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
if (chatMessages) chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
if (data.done) {
|
||||||
|
// 流结束,可以拿到 confidence_score 等元数据
|
||||||
|
if (data.confidence_score != null) {
|
||||||
|
msgEl.dataset.confidence = data.confidence_score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.error) {
|
||||||
|
fullContent += `\n[错误: ${data.error}]`;
|
||||||
|
contentEl.textContent = fullContent;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
if (!fullContent) {
|
||||||
this.addMessage('assistant', '抱歉,我暂时无法处理您的问题。请稍后再试。');
|
contentEl.textContent = '抱歉,我暂时无法处理您的问题。请稍后再试。';
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -199,7 +240,7 @@ class ChatHttpClient {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
addMessage(role, content, metadata = {}) {
|
addMessage(role, content, metadata = {}, streaming = false) {
|
||||||
const messagesContainer = document.getElementById('chat-messages');
|
const messagesContainer = document.getElementById('chat-messages');
|
||||||
|
|
||||||
// 如果是第一条消息,清空欢迎信息
|
// 如果是第一条消息,清空欢迎信息
|
||||||
@@ -216,13 +257,19 @@ class ChatHttpClient {
|
|||||||
|
|
||||||
const contentDiv = document.createElement('div');
|
const contentDiv = document.createElement('div');
|
||||||
contentDiv.className = 'message-content';
|
contentDiv.className = 'message-content';
|
||||||
contentDiv.innerHTML = content;
|
if (!streaming) {
|
||||||
|
contentDiv.innerHTML = content;
|
||||||
|
} else {
|
||||||
|
contentDiv.textContent = content;
|
||||||
|
}
|
||||||
|
|
||||||
// 添加时间戳
|
// 添加时间戳
|
||||||
const timeDiv = document.createElement('div');
|
const timeDiv = document.createElement('div');
|
||||||
timeDiv.className = 'message-time';
|
timeDiv.className = 'message-time';
|
||||||
timeDiv.textContent = new Date().toLocaleTimeString();
|
timeDiv.textContent = new Date().toLocaleTimeString();
|
||||||
contentDiv.appendChild(timeDiv);
|
if (!streaming) {
|
||||||
|
contentDiv.appendChild(timeDiv);
|
||||||
|
}
|
||||||
|
|
||||||
// 添加元数据
|
// 添加元数据
|
||||||
if (metadata.knowledge_used && metadata.knowledge_used.length > 0) {
|
if (metadata.knowledge_used && metadata.knowledge_used.length > 0) {
|
||||||
@@ -258,6 +305,7 @@ class ChatHttpClient {
|
|||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
|
||||||
this.messageCount++;
|
this.messageCount++;
|
||||||
|
return messageDiv;
|
||||||
}
|
}
|
||||||
|
|
||||||
addSystemMessage(content) {
|
addSystemMessage(content) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -662,19 +662,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<small class="text-white-50">当前状态: <span id="agent-current-state">空闲</span></small>
|
<small class="text-white-50">运行状态: <span id="agent-current-state" class="badge bg-success">active</span></small>
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<small class="text-white-50">活跃目标: <span id="agent-active-goals">0</span></small>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<small class="text-white-50">可用工具: <span id="agent-available-tools">0</span></small>
|
<small class="text-white-50">可用工具: <span id="agent-available-tools">0</span></small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-white-50">最大工具轮次: <span id="agent-max-rounds">5</span></small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-white-50">执行历史: <span id="agent-history-count">0</span> 条</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5><i class="fas fa-tools me-2"></i>工具管理</h5>
|
<h5><i class="fas fa-tools me-2"></i>ReAct 工具列表</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div id="tools-list">
|
<div id="tools-list">
|
||||||
@@ -684,23 +687,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5><i class="fas fa-plus me-2"></i>添加自定义工具</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<input type="text" class="form-control" id="tool-name" placeholder="工具名称">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<textarea class="form-control" id="tool-description" rows="3" placeholder="工具描述"></textarea>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary w-100" id="register-tool">
|
|
||||||
<i class="fas fa-plus me-1"></i>注册工具
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@@ -892,22 +878,27 @@
|
|||||||
|
|
||||||
<!-- 知识库标签页 -->
|
<!-- 知识库标签页 -->
|
||||||
<div id="knowledge-tab" class="tab-content" style="display: none;">
|
<div id="knowledge-tab" class="tab-content" style="display: none;">
|
||||||
|
<!-- 面包屑导航 -->
|
||||||
|
<div id="knowledge-breadcrumb" class="mb-3"></div>
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5><i class="fas fa-database me-2"></i>知识库管理</h5>
|
<h5><i class="fas fa-database me-2"></i>知识库管理</h5>
|
||||||
<div class="btn-group">
|
<div class="btn-group" id="knowledge-action-buttons">
|
||||||
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addKnowledgeModal">
|
<button class="btn btn-outline-secondary btn-sm" id="knowledge-refresh-btn" onclick="dashboard.refreshKnowledge()">
|
||||||
|
<i class="fas fa-sync-alt me-1"></i>刷新
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addKnowledgeModal" style="display:none" id="knowledge-add-btn">
|
||||||
<i class="fas fa-plus me-1"></i>添加知识
|
<i class="fas fa-plus me-1"></i>添加知识
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#uploadFileModal">
|
<button class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#uploadFileModal" style="display:none" id="knowledge-upload-btn">
|
||||||
<i class="fas fa-upload me-1"></i>上传文件
|
<i class="fas fa-upload me-1"></i>上传文件
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3" id="knowledge-search-bar" style="display:none">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control" id="knowledge-search" placeholder="搜索知识库...">
|
<input type="text" class="form-control" id="knowledge-search" placeholder="搜索知识库...">
|
||||||
<button class="btn btn-outline-secondary" id="search-knowledge">
|
<button class="btn btn-outline-secondary" id="search-knowledge">
|
||||||
@@ -915,13 +906,26 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="knowledge-list">
|
<!-- 租户卡片列表容器 -->
|
||||||
|
<div id="knowledge-tenant-list" class="row">
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="knowledge-pagination" class="mt-3">
|
<!-- 租户详情容器 -->
|
||||||
<!-- 分页控件将在这里显示 -->
|
<div id="knowledge-tenant-detail" style="display:none">
|
||||||
|
<div class="d-flex gap-2 mb-3" id="knowledge-filter-bar">
|
||||||
|
<select class="form-select form-select-sm" id="knowledge-category-filter" style="width:auto" onchange="dashboard.applyKnowledgeFilters()">
|
||||||
|
<option value="">全部分类</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-select form-select-sm" id="knowledge-verified-filter" style="width:auto" onchange="dashboard.applyKnowledgeFilters()">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="true">已验证</option>
|
||||||
|
<option value="false">未验证</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="knowledge-list"></div>
|
||||||
|
<div id="knowledge-pagination" class="mt-3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -943,7 +947,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<small class="text-muted">平均置信度</small>
|
<small class="text-muted">平均置信度</small>
|
||||||
<div class="progress">
|
<div class="progress">
|
||||||
<div class="progress-bar" id="knowledge-confidence" role="progressbar" style="width: 0%"></div>
|
<div class="progress-bar" id="knowledge-confidence-bar" role="progressbar" style="width: 0%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1252,6 +1256,8 @@
|
|||||||
|
|
||||||
<!-- 对话历史标签页 -->
|
<!-- 对话历史标签页 -->
|
||||||
<div id="conversation-history-tab" class="tab-content" style="display: none;">
|
<div id="conversation-history-tab" class="tab-content" style="display: none;">
|
||||||
|
<!-- 面包屑导航 -->
|
||||||
|
<div id="conversation-breadcrumb" class="mb-3"></div>
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -1267,38 +1273,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-3">
|
<!-- 租户卡片列表容器 -->
|
||||||
<div class="row">
|
<div id="conversation-tenant-list" class="row">
|
||||||
<div class="col-md-4">
|
|
||||||
<input type="text" class="form-control" id="conversation-search" placeholder="搜索对话内容...">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<select class="form-select" id="conversation-user-filter">
|
|
||||||
<option value="">全部用户</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<select class="form-select" id="conversation-date-filter">
|
|
||||||
<option value="">全部时间</option>
|
|
||||||
<option value="today">今天</option>
|
|
||||||
<option value="week">本周</option>
|
|
||||||
<option value="month">本月</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<button class="btn btn-outline-secondary w-100" onclick="dashboard.filterConversations()">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="conversation-list">
|
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="conversations-pagination" class="mt-3">
|
<!-- 租户详情容器 -->
|
||||||
<!-- 分页控件将在这里显示 -->
|
<div id="conversation-tenant-detail" style="display:none">
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<select class="form-select form-select-sm" id="conversation-status-filter" style="width:auto" onchange="dashboard.loadConversationTenantDetail(dashboard.conversationCurrentTenantId)">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="active">活跃</option>
|
||||||
|
<option value="ended">已结束</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-select form-select-sm" id="conversation-detail-date-filter" style="width:auto" onchange="dashboard.loadConversationTenantDetail(dashboard.conversationCurrentTenantId)">
|
||||||
|
<option value="">全部时间</option>
|
||||||
|
<option value="today">今天</option>
|
||||||
|
<option value="week">本周</option>
|
||||||
|
<option value="month">本月</option>
|
||||||
|
</select>
|
||||||
|
<div class="input-group input-group-sm" style="width:auto">
|
||||||
|
<input type="text" class="form-control" id="conversation-search" placeholder="搜索会话...">
|
||||||
|
<button class="btn btn-outline-secondary" onclick="dashboard.filterConversations()">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="conversation-session-list"></div>
|
||||||
|
<div id="conversation-session-pagination" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 保留原有容器用于向后兼容 -->
|
||||||
|
<div id="conversation-list" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div id="conversations-pagination" class="mt-3" style="display:none">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2085,6 +2093,21 @@
|
|||||||
|
|
||||||
<!-- 系统信息显示 -->
|
<!-- 系统信息显示 -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5><i class="fas fa-building me-2"></i>租户与模型信息</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tr><td class="text-muted" style="width:40%">租户ID</td><td id="setting-tenant-id">-</td></tr>
|
||||||
|
<tr><td class="text-muted">LLM Provider</td><td id="setting-llm-provider">-</td></tr>
|
||||||
|
<tr><td class="text-muted">LLM Model</td><td id="setting-llm-model">-</td></tr>
|
||||||
|
<tr><td class="text-muted">LLM Base URL</td><td id="setting-llm-base-url" style="word-break:break-all">-</td></tr>
|
||||||
|
<tr><td class="text-muted">Embedding</td><td id="setting-embedding-status">-</td></tr>
|
||||||
|
<tr><td class="text-muted">Redis</td><td id="setting-redis-status">-</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5><i class="fas fa-info-circle me-2"></i><span data-i18n="settings-system-info">系统信息</span></h5>
|
<h5><i class="fas fa-info-circle me-2"></i><span data-i18n="settings-system-info">系统信息</span></h5>
|
||||||
|
|||||||
Reference in New Issue
Block a user