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:
2026-04-01 16:11:02 +08:00
parent e14e3ee7a5
commit 7013e9db70
27 changed files with 2753 additions and 276 deletions

3
.env
View File

@@ -21,6 +21,9 @@ DEBUG_MODE=False
# Logging level for the application. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO
# 租户标识 — 多项目共用同一套代码时,用不同的 TENANT_ID 隔离数据
TENANT_ID=default
# ============================================================================
# DATABASE CONFIGURATION

View File

@@ -21,6 +21,9 @@ DEBUG_MODE=False
# Logging level for the application. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL=INFO
# 租户标识 — 多项目共用同一套代码时,用不同的 TENANT_ID 隔离数据
TENANT_ID=default
# ============================================================================
# DATABASE CONFIGURATION

View File

@@ -0,0 +1 @@
{"specId": "b7e3c1a2-5f84-4d9e-a1b3-8c6d2e4f7a90", "workflowType": "requirements-first", "specType": "feature"}

View 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/&lt;id&gt;"]
EP5["DELETE /api/conversations/sessions/&lt;id&gt;"]
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 SummaryAPI 响应结构,非持久化)
```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):
...
```

View 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

View 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) 保持一致

View File

@@ -0,0 +1 @@
{"specId": "0d6981a4-ab44-429e-966d-0874ce82383c", "workflowType": "requirements-first", "specType": "feature"}

View 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 SummaryAPI 响应结构,非持久化)
```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):
...
```

View 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

View 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.

View File

@@ -199,6 +199,7 @@ class DatabaseInitializer:
self._migrate_analytics_enhancements,
self._migrate_system_optimization_fields,
self._migrate_chat_sessions_table,
self._migrate_tenant_id_fields,
]
success_count = 0
@@ -477,6 +478,30 @@ class DatabaseInitializer:
print(f" 会话管理表迁移失败: {e}")
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:
"""为表添加字段"""
try:

View File

@@ -56,6 +56,19 @@ http {
proxy_send_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代理
location /ws/ {

58
scripts/migrate_tenant.py Normal file
View 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()

View File

@@ -49,6 +49,7 @@ class ServerConfig:
websocket_port: int = 8765
debug: bool = False
log_level: str = "INFO"
tenant_id: str = "default" # 当前实例的租户标识
@dataclass
class FeishuConfig:
@@ -145,7 +146,8 @@ class UnifiedConfig:
port=int(os.getenv("SERVER_PORT", 5000)),
websocket_port=int(os.getenv("WEBSOCKET_PORT", 8765)),
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.")
return config

View File

@@ -1,35 +1,50 @@
# -*- coding: utf-8 -*-
"""
统一 LLM 客户端
兼容所有 OpenAI 格式 API千问、Gemini、DeepSeek、本地 Ollama 等)
通过 .env 中 LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL 切换模型
"""
import requests
import json
import logging
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional, Any, Generator
from datetime import datetime
from src.config.unified_config import get_config
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()
self.base_url = config.llm.base_url or "https://dashscope.aliyuncs.com/compatible-mode/v1"
self.api_key = config.llm.api_key
self.model_name = config.llm.model
self.timeout = config.llm.timeout
self.base_url = (base_url or config.llm.base_url or
"https://dashscope.aliyuncs.com/compatible-mode/v1")
self.api_key = api_key or config.llm.api_key
self.model_name = model or config.llm.model
self.timeout = timeout or config.llm.timeout
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
"Content-Type": "application/json",
}
# ── 普通请求 ──────────────────────────────────────────
def chat_completion(
self,
messages: List[Dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 1000,
stream: bool = False
**kwargs,
) -> Dict[str, Any]:
"""发送聊天请求"""
"""标准聊天补全(非流式)"""
try:
url = f"{self.base_url}/chat/completions"
payload = {
@@ -37,114 +52,146 @@ class QwenClient:
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": stream
"stream": False,
}
response = requests.post(
url,
headers=self.headers,
json=payload,
timeout=self.timeout
url, headers=self.headers, json=payload, timeout=self.timeout
)
if response.status_code == 200:
result = response.json()
logger.info("API请求成功")
return result
return response.json()
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}"}
except requests.exceptions.Timeout:
logger.error("API请求超时")
logger.error("LLM API 超时")
return {"error": "请求超时"}
except requests.exceptions.RequestException as e:
logger.error(f"API请求异常: {e}")
logger.error(f"LLM API 异常: {e}")
return {"error": f"请求异常: {str(e)}"}
except Exception as e:
logger.error(f"未知错误: {e}")
logger.error(f"LLM 未知错误: {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(
self,
user_message: str,
context: Optional[str] = None,
knowledge_base: Optional[List[str]] = None
knowledge_base: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""生成回复"""
messages = []
# 系统提示词
system_prompt = "你是一个专业的客服助手,请根据用户问题提供准确、 helpful的回复。"
"""快捷生成回复"""
system_prompt = "你是一个专业的客服助手,请根据用户问题提供准确、有帮助的回复。"
if context:
system_prompt += f"\n\n上下文信息: {context}"
if knowledge_base:
system_prompt += f"\n\n相关知识库: {' '.join(knowledge_base)}"
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": user_message})
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
]
result = self.chat_completion(messages)
if "error" in result:
return result
try:
response_content = result["choices"][0]["message"]["content"]
return {
"response": response_content,
"response": result["choices"][0]["message"]["content"],
"usage": result.get("usage", {}),
"model": result.get("model", ""),
"timestamp": datetime.now().isoformat()
"timestamp": datetime.now().isoformat(),
}
except (KeyError, IndexError) as e:
logger.error(f"解析API响应失败: {e}")
logger.error(f"解析响应失败: {e}")
return {"error": f"解析响应失败: {str(e)}"}
def extract_entities(self, text: str) -> Dict[str, Any]:
"""提取文本中的实体信息"""
prompt = f"""
请从以下文本中提取关键信息,包括:
1. 问题类型/类别
2. 优先级(高/中/低)
3. 关键词
4. 情感倾向(正面/负面/中性)
文本: {text}
请以JSON格式返回结果。
"""
import re
prompt = (
f"请从以下文本中提取关键信息,包括:\n"
f"1. 问题类型/类别\n2. 优先级(高/中/低)\n"
f"3. 关键词\n4. 情感倾向(正面/负面/中性)\n\n"
f"文本: {text}\n\n请以JSON格式返回结果。"
)
messages = [
{"role": "system", "content": "你是一个信息提取专家,请准确提取文本中的关键信息。"},
{"role": "user", "content": prompt}
{"role": "user", "content": prompt},
]
result = self.chat_completion(messages, temperature=0.3)
if "error" in result:
return result
try:
response_content = result["choices"][0]["message"]["content"]
# 尝试解析JSON
import re
json_match = re.search(r'\{.*\}', response_content, re.DOTALL)
if json_match:
return json.loads(json_match.group())
else:
return {"raw_response": response_content}
content = result["choices"][0]["message"]["content"]
json_match = re.search(r'\{.*\}', content, re.DOTALL)
return json.loads(json_match.group()) if json_match else {"raw_response": content}
except Exception as e:
logger.error(f"解析实体提取结果失败: {e}")
return {"error": f"解析失败: {str(e)}"}
def test_connection(self) -> bool:
"""测试API连接"""
"""测试连接"""
try:
result = self.chat_completion([
{"role": "user", "content": "你好"}
], max_tokens=10)
result = self.chat_completion(
[{"role": "user", "content": "你好"}], max_tokens=10
)
return "error" not in result
except Exception as e:
logger.error(f"API连接测试失败: {e}")
except Exception:
return False
# ── 向后兼容别名 ──────────────────────────────────────────
# 旧代码中 `from src.core.llm_client import QwenClient` 仍然能用
QwenClient = LLMClient

View File

@@ -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.orm import relationship
from datetime import datetime
@@ -6,11 +6,15 @@ import hashlib
Base = declarative_base()
# 默认租户ID单租户部署时使用
DEFAULT_TENANT = "default"
class WorkOrder(Base):
"""工单模型"""
__tablename__ = "work_orders"
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)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
@@ -63,6 +67,7 @@ class ChatSession(Base):
__tablename__ = "chat_sessions"
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) # 唯一会话标识
user_id = Column(String(100), nullable=True) # 用户标识
work_order_id = Column(Integer, ForeignKey("work_orders.id"), nullable=True)
@@ -100,6 +105,7 @@ class Conversation(Base):
__tablename__ = "conversations"
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) # 关联会话
work_order_id = Column(Integer, ForeignKey("work_orders.id"))
user_message = Column(Text, nullable=False)
@@ -124,6 +130,7 @@ class KnowledgeEntry(Base):
__tablename__ = "knowledge_entries"
id = Column(Integer, primary_key=True)
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
question = Column(Text, nullable=False)
answer = Column(Text, nullable=False)
category = Column(String(100), nullable=False)
@@ -164,6 +171,7 @@ class Analytics(Base):
__tablename__ = "analytics"
id = Column(Integer, primary_key=True)
tenant_id = Column(String(50), nullable=False, default=DEFAULT_TENANT, index=True)
date = Column(DateTime, nullable=False)
total_orders = Column(Integer, default=0)
resolved_orders = Column(Integer, default=0)
@@ -184,6 +192,7 @@ class Alert(Base):
__tablename__ = "alerts"
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)
alert_type = Column(String(50), nullable=False)
level = Column(String(20), nullable=False) # info, warning, error, critical
@@ -242,6 +251,7 @@ class User(Base):
__tablename__ = "users"
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)
password_hash = Column(String(128), nullable=False)
email = Column(String(120), unique=True, nullable=True)

View File

@@ -14,7 +14,7 @@ from ..core.database import db_manager
from ..core.models import Conversation, WorkOrder, WorkOrderSuggestion, KnowledgeEntry, ChatSession
from ..core.redis_manager import redis_manager
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__)
@@ -634,7 +634,8 @@ class ConversationHistoryManager:
def get_conversation_analytics(
self,
work_order_id: Optional[int] = None,
days: int = 7
days: int = 7,
tenant_id: Optional[str] = None
) -> Dict[str, Any]:
"""获取对话分析数据包含AI建议统计"""
try:
@@ -652,6 +653,8 @@ class ConversationHistoryManager:
conv_query = session.query(Conversation)
if 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(
Conversation.timestamp >= cutoff_date
@@ -718,6 +721,49 @@ class ConversationHistoryManager:
logger.error(f"获取对话分析数据失败: {e}")
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(
@@ -726,13 +772,17 @@ class ConversationHistoryManager:
per_page: int = 20,
status: Optional[str] = None,
search: str = '',
date_filter: str = ''
date_filter: str = '',
tenant_id: Optional[str] = None
) -> Dict[str, Any]:
"""分页获取会话列表"""
try:
with db_manager.get_session() as session:
query = session.query(ChatSession)
if tenant_id is not None:
query = query.filter(ChatSession.tenant_id == tenant_id)
if status:
query = query.filter(ChatSession.status == status)

View File

@@ -232,6 +232,98 @@ class RealtimeChatManager:
"confidence": 0.1,
"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:
"""构建聊天提示词"""

View File

@@ -5,7 +5,7 @@ from datetime import datetime
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sqlalchemy import func
from sqlalchemy import func, Integer
from ..core.database import db_manager
from ..core.models import KnowledgeEntry, WorkOrder, Conversation
@@ -162,24 +162,24 @@ class KnowledgeManager:
logger.error(f"查找相似条目失败: {e}")
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 语义检索,降级为关键词匹配"""
try:
# 尝试 embedding 语义检索
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:
return results
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:
logger.error(f"搜索知识库失败: {e}")
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 向量的语义检索"""
try:
query_vec = self.embedding_client.embed_text(query)
@@ -205,6 +205,8 @@ class KnowledgeManager:
KnowledgeEntry.id.in_(candidate_ids),
KnowledgeEntry.is_active == True
)
if tenant_id is not None:
query_filter = query_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
if verified_only:
query_filter = query_filter.filter(KnowledgeEntry.is_verified == True)
@@ -212,10 +214,13 @@ class KnowledgeManager:
# 如果 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.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 = []
for entry in entries:
@@ -240,7 +245,7 @@ class KnowledgeManager:
logger.error(f"Embedding 搜索失败: {e}")
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:
with db_manager.get_session() as session:
@@ -249,6 +254,9 @@ class KnowledgeManager:
KnowledgeEntry.is_active == True
)
if tenant_id is not None:
query_filter = query_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
# 如果只搜索已验证的知识库
if verified_only:
query_filter = query_filter.filter(KnowledgeEntry.is_verified == True)
@@ -256,7 +264,10 @@ class KnowledgeManager:
entries = query_filter.all()
# 若已验证为空,则回退到全部活跃条目
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:
logger.warning("知识库中没有活跃条目")
@@ -334,10 +345,14 @@ class KnowledgeManager:
answer: str,
category: str,
confidence_score: float = 0.5,
is_verified: bool = False
is_verified: bool = False,
tenant_id: Optional[str] = None
) -> bool:
"""添加知识库条目"""
try:
# 确定 tenant_id优先使用传入值否则取配置默认值
effective_tenant_id = tenant_id if tenant_id is not None else get_config().server.tenant_id
# 生成 embedding
embedding_json = None
text_for_embedding = question + " " + answer
@@ -354,6 +369,7 @@ class KnowledgeManager:
confidence_score=confidence_score,
usage_count=0,
is_verified=is_verified,
tenant_id=effective_tenant_id,
vector_embedding=embedding_json
)
session.add(entry)
@@ -541,18 +557,23 @@ class KnowledgeManager:
logger.error(f"删除知识库条目失败: {e}")
return False
def get_knowledge_stats(self) -> Dict[str, Any]:
def get_knowledge_stats(self, tenant_id: Optional[str] = None) -> Dict[str, Any]:
"""获取知识库统计信息"""
try:
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(
KnowledgeEntry.is_active == True
*base_filter
).count()
# 统计已验证的条目
verified_entries = session.query(KnowledgeEntry).filter(
KnowledgeEntry.is_active == True,
*base_filter,
KnowledgeEntry.is_verified == True
).count()
@@ -561,27 +582,100 @@ class KnowledgeManager:
KnowledgeEntry.category,
func.count(KnowledgeEntry.id)
).filter(
KnowledgeEntry.is_active == True
*base_filter
).group_by(KnowledgeEntry.category).all()
# 平均置信度(仅限活跃条目)
avg_confidence = session.query(
func.avg(KnowledgeEntry.confidence_score)
).filter(
KnowledgeEntry.is_active == True
*base_filter
).scalar() or 0.0
return {
result = {
"total_entries": total_entries,
"active_entries": verified_entries, # 将 active_entries 复用为已验证数量,或前端相应修改
"category_distribution": dict(category_stats),
"average_confidence": float(avg_confidence)
}
if tenant_id is not None:
result["tenant_id"] = tenant_id
return result
except Exception as e:
logger.error(f"获取知识库统计失败: {e}")
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:
"""更新知识库条目的使用次数"""
try:
@@ -602,12 +696,15 @@ class KnowledgeManager:
logger.error(f"更新知识库使用次数失败: {e}")
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:
with db_manager.get_session() as session:
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:
query = query.filter(KnowledgeEntry.category == category_filter)
if verified_filter:

View File

@@ -11,7 +11,7 @@ import logging
from datetime import datetime, timedelta
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 src.config.unified_config import get_config
@@ -207,6 +207,33 @@ def send_chat_message():
except Exception as e:
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>')
def get_chat_history(session_id):
"""获取对话历史"""

View File

@@ -335,10 +335,12 @@ def get_conversation_analytics():
try:
work_order_id = request.args.get('work_order_id', type=int)
days = request.args.get('days', 7, type=int)
tenant_id = request.args.get('tenant_id')
analytics = history_manager.get_conversation_analytics(
work_order_id=work_order_id,
days=days
days=days,
tenant_id=tenant_id
)
return jsonify({
@@ -351,6 +353,17 @@ def get_conversation_analytics():
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 ====================
@conversations_bp.route('/sessions')
@@ -362,13 +375,15 @@ def get_sessions():
status = request.args.get('status', '') # active, ended, 空=全部
search = request.args.get('search', '')
date_filter = request.args.get('date_filter', '')
tenant_id = request.args.get('tenant_id')
result = history_manager.get_sessions_paginated(
page=page,
per_page=per_page,
status=status or None,
search=search,
date_filter=date_filter
date_filter=date_filter,
tenant_id=tenant_id
)
return jsonify(result)

View File

@@ -25,6 +25,18 @@ def get_agent_assistant():
_agent_assistant = TSPAgentAssistant()
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('')
@handle_api_errors
def get_knowledge():
@@ -33,12 +45,14 @@ def get_knowledge():
per_page = request.args.get('per_page', 10, type=int)
category_filter = request.args.get('category', '')
verified_filter = request.args.get('verified', '')
tenant_id = request.args.get('tenant_id')
result = service_manager.get_assistant().knowledge_manager.get_knowledge_paginated(
page=page,
per_page=per_page,
category_filter=category_filter,
verified_filter=verified_filter
verified_filter=verified_filter,
tenant_id=tenant_id
)
return jsonify(result)
@@ -47,6 +61,7 @@ def get_knowledge():
def search_knowledge():
"""搜索知识库"""
query = request.args.get('q', '')
tenant_id = request.args.get('tenant_id')
logger = logging.getLogger(__name__)
logger.info(f"搜索查询: '{query}'")
@@ -55,7 +70,7 @@ def search_knowledge():
return jsonify([])
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)}")
return jsonify(results)
@@ -64,11 +79,13 @@ def search_knowledge():
def add_knowledge():
"""添加知识库条目"""
data = request.get_json()
tenant_id = data.get('tenant_id')
success = service_manager.get_assistant().knowledge_manager.add_knowledge_entry(
question=data['question'],
answer=data['answer'],
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:
return create_success_response("知识添加成功")
@@ -79,7 +96,8 @@ def add_knowledge():
@handle_api_errors
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)
@knowledge_bp.route('/upload', methods=['POST'])

View File

@@ -63,6 +63,42 @@ def get_settings():
except Exception as e:
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'])
def save_settings():
"""保存系统设置"""

View File

@@ -104,28 +104,69 @@ class ChatHttpClient {
this.showTypingIndicator();
try {
const response = await this.sendRequest('POST', '/message', {
session_id: this.sessionId,
message: message
// 使用流式接口
const response = await fetch('/api/chat/message/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: this.sessionId, message: message })
});
this.hideTypingIndicator();
if (response.success) {
// 添加助手回复
this.addMessage('assistant', response.content, {
knowledge_used: response.knowledge_used,
confidence_score: response.confidence_score,
work_order_id: response.work_order_id
});
// 更新工单ID
if (response.work_order_id) {
document.getElementById('work-order-id').value = response.work_order_id;
if (!response.ok) {
this.addMessage('assistant', '请求失败,请稍后再试。');
return;
}
// 创建一个空的助手消息容器用于流式填充
const msgEl = this.addMessage('assistant', '', {}, true);
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 {
this.addMessage('assistant', '抱歉,我暂时无法处理您的问题。请稍后再试。');
}
if (!fullContent) {
contentEl.textContent = '抱歉,我暂时无法处理您的问题。请稍后再试。';
}
} catch (error) {
@@ -199,7 +240,7 @@ class ChatHttpClient {
return await response.json();
}
addMessage(role, content, metadata = {}) {
addMessage(role, content, metadata = {}, streaming = false) {
const messagesContainer = document.getElementById('chat-messages');
// 如果是第一条消息,清空欢迎信息
@@ -216,13 +257,19 @@ class ChatHttpClient {
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.innerHTML = content;
if (!streaming) {
contentDiv.innerHTML = content;
} else {
contentDiv.textContent = content;
}
// 添加时间戳
const timeDiv = document.createElement('div');
timeDiv.className = 'message-time';
timeDiv.textContent = new Date().toLocaleTimeString();
contentDiv.appendChild(timeDiv);
if (!streaming) {
contentDiv.appendChild(timeDiv);
}
// 添加元数据
if (metadata.knowledge_used && metadata.knowledge_used.length > 0) {
@@ -258,6 +305,7 @@ class ChatHttpClient {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
this.messageCount++;
return messageDiv;
}
addSystemMessage(content) {

File diff suppressed because it is too large Load Diff

View File

@@ -662,19 +662,22 @@
</div>
</div>
<div class="mb-2">
<small class="text-white-50">当前状态: <span id="agent-current-state">空闲</span></small>
</div>
<div class="mb-2">
<small class="text-white-50">活跃目标: <span id="agent-active-goals">0</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-available-tools">0</span></small>
</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 class="card mb-3">
<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 class="card-body">
<div id="tools-list">
@@ -684,23 +687,6 @@
</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 class="col-md-8">
@@ -892,22 +878,27 @@
<!-- 知识库标签页 -->
<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="col-md-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="fas fa-database me-2"></i>知识库管理</h5>
<div class="btn-group">
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addKnowledgeModal">
<div class="btn-group" id="knowledge-action-buttons">
<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>添加知识
</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>上传文件
</button>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<div class="mb-3" id="knowledge-search-bar" style="display:none">
<div class="input-group">
<input type="text" class="form-control" id="knowledge-search" placeholder="搜索知识库...">
<button class="btn btn-outline-secondary" id="search-knowledge">
@@ -915,13 +906,26 @@
</button>
</div>
</div>
<div id="knowledge-list">
<!-- 租户卡片列表容器 -->
<div id="knowledge-tenant-list" class="row">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
</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>
@@ -943,7 +947,7 @@
<div class="mb-3">
<small class="text-muted">平均置信度</small>
<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>
@@ -1252,6 +1256,8 @@
<!-- 对话历史标签页 -->
<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="col-md-8">
<div class="card">
@@ -1267,38 +1273,40 @@
</div>
</div>
<div class="card-body">
<div class="mb-3">
<div 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 id="conversation-tenant-list" class="row">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
</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>
@@ -2085,6 +2093,21 @@
<!-- 系统信息显示 -->
<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-header">
<h5><i class="fas fa-info-circle me-2"></i><span data-i18n="settings-system-info">系统信息</span></h5>