From 8773991015fd4687808d1f4209cf2191368e26d2 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Mon, 28 Sep 2026 11:28:24 +0800 Subject: [PATCH] feat: add workbench AI integration --- README.md | 24 +- configs/workbench.local.json | 15 +- docs/IM工作台AI前后端对接文档.md | 1625 ++++++++++++++++++++++++ docs/IM工作台AI后端概要设计文档.md | 1289 +++++++++++++++++++ docs/IM工作台AI接入需求文档.md | 1302 +++++++++++++++++++ docs/sql/IM-工作台AI建表SQL.sql | 230 ++++ go.mod | 7 + go.sum | 14 + internal/ai/provider.go | 197 +++ internal/ai/service.go | 387 ++++++ internal/app/app.go | 4 +- internal/config/config.go | 71 +- internal/config/config_test.go | 6 +- internal/httpapi/ai.go | 1022 +++++++++++++++ internal/httpapi/api.go | 20 +- internal/store/ai.go | 459 +++++++ internal/store/mysql.go | 6 + internal/tableparse/tableparse.go | 254 ++++ internal/tableparse/tableparse_test.go | 49 + 19 files changed, 6971 insertions(+), 10 deletions(-) create mode 100644 docs/IM工作台AI前后端对接文档.md create mode 100644 docs/IM工作台AI后端概要设计文档.md create mode 100644 docs/IM工作台AI接入需求文档.md create mode 100644 docs/sql/IM-工作台AI建表SQL.sql create mode 100644 internal/ai/provider.go create mode 100644 internal/ai/service.go create mode 100644 internal/httpapi/ai.go create mode 100644 internal/store/ai.go create mode 100644 internal/tableparse/tableparse.go create mode 100644 internal/tableparse/tableparse_test.go diff --git a/README.md b/README.md index 3f7cba6..9d5d7cc 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ IM_WORKBENCH_INTERNAL_SECRET=local-workbench-secret \ go run ./cmd/workbench ``` -默认监听 `:6061`,默认连接本地 `meeting_im` 库和本地 Redis。 +默认监听 `:6061`,默认连接本地 `workbench` 库和本地 Redis。 开发联调可在 `configs/workbench.local.json` 打开 `allow_dev_header_auth`,请求头带: @@ -92,6 +92,28 @@ go run ./cmd/workbench 测试环境使用 `configs/workbench.test.json`,Kafka 为明文 PLAINTEXT 直连,不配置 SASL/TLS。 +## AI / DeepSeek 本地配置 + +工作台 AI 首版接入 DeepSeek。默认配置已在 `configs/workbench.local.json` 中给出,实际调用前需要配置 API Key: + +```bash +DEEPSEEK_API_KEY=sk-xxx \ +IM_WORKBENCH_CONFIG=configs/workbench.local.json \ +go run ./cmd/workbench +``` + +可选参数: + +```bash +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_DEFAULT_MODEL=deepseek-flash +DEEPSEEK_COMPLEX_MODEL=deepseek-v4-pro +DEEPSEEK_TIMEOUT_MS=60000 +DEEPSEEK_MAX_RETRIES=2 +DEEPSEEK_TEMPERATURE=0.2 +DEEPSEEK_MAX_OUTPUT_TOKENS=4096 +``` + 事件体字段: ```json diff --git a/configs/workbench.local.json b/configs/workbench.local.json index 6844178..dd9ca71 100644 --- a/configs/workbench.local.json +++ b/configs/workbench.local.json @@ -1,7 +1,7 @@ { "listen": ":6061", "mysql": { - "dsn": "root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local", + "dsn": "root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true&charset=utf8mb4,utf8&loc=Local", "max_open_conns": 15, "max_idle_conns": 5 }, @@ -40,5 +40,18 @@ "uid_key" ], "timeout_ms": 3000 + }, + "ai": { + "provider": "deepseek", + "default_model": "deepseek-flash", + "complex_model": "deepseek-v4-pro", + "temperature": 0.2, + "max_output_tokens": 4096, + "deepseek": { + "base_url": "https://api.deepseek.com", + "api_key": "", + "timeout_ms": 60000, + "max_retries": 2 + } } } diff --git a/docs/IM工作台AI前后端对接文档.md b/docs/IM工作台AI前后端对接文档.md new file mode 100644 index 0000000..0b26128 --- /dev/null +++ b/docs/IM工作台AI前后端对接文档.md @@ -0,0 +1,1625 @@ +# IM 工作台 AI 前后端对接文档 + +> 版本:v1.0 +> 日期:2026-09-25 +> 需求依据:[IM 工作台 AI 接入需求文档](./IM工作台AI接入需求文档.md) +> IM 依据:[IM 前后端对接文档](./IM-前后端对接文档.md) +> 参考格式:[禾圃集后院养成与物友动态前后端对接文档](./禾圃集-后院养成与物友动态前后端对接文档.md) +> 说明:本文补充 IM 工作台 AI 能力的前后端对接契约,包括工作台 AI 入口、应用搜索、智能文档、智能表格、AI 美文、日报/月报 AI 接入、会议笔记 AI 总结、AI 任务与记录。IM 登录、租户、用户体系、文件服务、工作台原有日报/月报/会议接口沿用现有 IM 与 workbench 协议。 + +## 0. 公共对接约定 + +### 0.1 实现状态 + +| 状态 | 含义 | +| --- | --- | +| `新增` | 当前工作台接口未覆盖,需要新增服务端模型和接口 | +| `复用` | 沿用 IM 或 workbench 既有能力,例如登录、租户、文件上传、日报/月报提交 | +| `需改造` | 已有基础能力,但需要补充 AI 字段、记录、状态或权限判断 | +| `纯客户端` | 只影响本地 UI、动效、输入缓存或前端路由,不新增服务端接口 | + +本文所有接口均为目标契约。接口完成前,前端可按相同字段结构使用 Mock;联调时以本文字段名、枚举值、错误码和幂等规则为准。所有 ID、游标、用户 ID、会议 ID、文件 ID 统一按字符串处理。 + +### 0.2 鉴权、租户与路径 + +工作台 AI 接口复用 IM 登录态: + +1. 客户端必须先按 IM 登录流程拿到用户 Token,再访问本文接口。 +2. 服务端从 Token 与 Header 解析当前租户和用户,客户端不得在请求体中传 `tenant_id`、`user_id` 来覆盖身份。 +3. 工作台 AI 数据必须按租户、用户隔离;用户只能访问本人创建的 AI 任务和记录,会议 AI 总结按会议笔记权限判断。 +4. 写接口必须支持幂等,客户端传 `Idempotency-Key`;同一幂等键请求体不同,返回 `IDEMPOTENCY_CONFLICT`。 +5. AI 内容、原始输入、上传文件解析结果、模型响应和错误日志均需脱敏后入库或打印。 + +建议路径前缀: + +```text +https://{host}/v1/workbench/ai/* +``` + +公共 Header: + +| Header | 必填 | 说明 | +| --- | --- | --- | +| `Authorization` | 是 | `Bearer ` | +| `X-MeetingIM-APIKey` | 是 | 沿用 IM 客户端预置 API Key | +| `X-IM-Schema` | 是 | 固定传 `1` | +| `X-Tenant-Code` | 是 | 当前租户编码,例如 `system` | +| `X-Request-ID` | 是 | 客户端生成的请求追踪 ID | +| `Idempotency-Key` | 写接口必填 | 幂等键,避免弱网重复提交 | + +文件上传沿用 IM 文件服务。智能表格原始文件建议上传为 `purpose=ai_table_source`,会议笔记附件仍沿用会议笔记既有 `purpose`。 + +### 0.3 成功与失败响应 + +成功响应统一为: + +```json +{ + "code": 0, + "data": {}, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWB", + "server_time": "2026-09-25T08:30:15.123Z" +} +``` + +失败响应统一为: + +```json +{ + "code": 30000, + "reason": "MALFORMED_REQUEST", + "message": "malformed request", + "details": { + "field": "source_text", + "limit": 1000 + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWB", + "server_time": "2026-09-25T08:30:15.123Z" +} +``` + +前端只使用稳定 `reason` 分支和本地化文案,不解析 `message`。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。时间统一为 UTC RFC3339。 + +### 0.4 枚举约定 + +#### 0.4.1 AI 能力类型 + +| 枚举 | 说明 | +| --- | --- | +| `smart_document` | 智能文档 | +| `smart_table` | 智能表格/报告解读 | +| `text_polish` | AI 美文 | +| `daily_report_polish` | 日报 AI 美化 | +| `monthly_report_polish` | 月报 AI 美化 | +| `meeting_summary` | 会议笔记 AI 总结 | + +#### 0.4.2 AI 任务状态 + +| 枚举 | 说明 | +| --- | --- | +| `queued` | 已入队,等待执行 | +| `processing` | 处理中 | +| `succeeded` | 处理成功 | +| `failed` | 处理失败 | +| `canceled` | 已取消 | +| `expired` | 结果已过期 | + +#### 0.4.3 AI 记录类型 + +| 枚举 | 说明 | +| --- | --- | +| `smart_document` | 智能文档记录 | +| `smart_table` | 智能表格解读记录 | +| `text_polish` | AI 美文记录,首版可不展示到记录中心 | +| `meeting_summary` | 会议总结记录,挂在会议下 | + +#### 0.4.4 文件类型 + +| 枚举 | 说明 | +| --- | --- | +| `xlsx` | Excel 工作簿 | +| `xls` | 旧版 Excel 工作簿 | +| `csv` | CSV 数据文件 | +| `docx` | Word 文档,导出场景使用 | +| `pdf` | PDF 文档,导出场景使用 | + +#### 0.4.5 表格解读步骤 + +| 枚举 | 说明 | +| --- | --- | +| `read_file` | 读取表格数据 | +| `detect_metrics` | 识别核心指标 | +| `analyze_trends` | 分析趋势变化 | +| `generate_report` | 生成解读报告 | + +### 0.5 通用错误码 + +| reason | HTTP | 说明 | 前端处理 | +| --- | --- | --- | --- | +| `UNAUTHORIZED` | 401 | Token 缺失、过期或无效 | 回到登录 | +| `FORBIDDEN` | 403 | 无权限访问 AI 能力、记录或会议 | 展示无权限 | +| `MALFORMED_REQUEST` | 400 | 请求体格式错误 | 按接口提示修正 | +| `VALIDATION_FAILED` | 400 | 字段校验失败 | 按字段提示 | +| `RESOURCE_NOT_FOUND` | 404 | 对象不存在或无权感知 | 展示不存在或已失效 | +| `IDEMPOTENCY_CONFLICT` | 409 | 同一幂等键请求体不一致 | 停止重试并上报 | +| `AI_QUOTA_EXCEEDED` | 429 | AI 用量超限 | 提示今日次数已用完 | +| `RATE_LIMITED` | 429 | 触发频控 | 提示稍后再试 | +| `AI_PROVIDER_FAILED` | 502 | AI 供应商调用失败 | 可重试 | +| `AI_TIMEOUT` | 504 | AI 处理超时 | 展示超时并允许重试 | +| `AI_CONTENT_REJECTED` | 422 | 输入或输出命中内容安全 | 提示内容不适合生成 | +| `AI_TASK_FAILED` | 500 | AI 任务执行失败 | 展示失败并允许重试 | +| `FILE_TOO_LARGE` | 413 | 文件超过限制 | 提示压缩或更换文件 | +| `FILE_TYPE_UNSUPPORTED` | 415 | 文件类型不支持 | 提示仅支持 xls/xlsx/csv | +| `FILE_UNAVAILABLE` | 404 | 文件不存在、已过期或无权限 | 重新上传 | + +### 0.6 分页、轮询与幂等 + +| 场景 | 约定 | +| --- | --- | +| 列表分页 | `limit` 默认 20,最大 50;`cursor` 为服务端不透明字符串 | +| 任务轮询 | 前端创建任务后每 1.5 秒轮询一次,连续 30 秒后改为每 3 秒,最长 2 分钟 | +| 弱网重试 | 写接口使用同一个 `Idempotency-Key` 重试 | +| 取消任务 | 只允许取消 `queued`、`processing` 状态任务 | +| 记录保存 | 智能文档生成成功后,必须显式保存才进入文档记录;智能表格解读成功后默认生成记录 | + +## 1. 工作台 AI 入口与应用搜索 + +本章对应工作台首页、搜索默认页、搜索结果页和无结果页。工作台原有应用仍走既有接口;本文补充 AI 能力开关、限制和搜索字段。 + +### 1.1 获取工作台 AI 能力 + +#### 需求图片 + +工作台 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `GET /v1/workbench/ai/capabilities` | +| 用途 | 获取当前租户、当前用户可用的 AI 能力、用量限制和入口展示状态 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 读接口无需幂等 | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `scene` | string | 否 | 调用场景,默认 `workbench_home` | + +#### 入参示例 + +```http +GET /v1/workbench/ai/capabilities?scene=workbench_home HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWB +``` + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `enabled` | boolean | 工作台 AI 总开关 | +| `features[]` | array | 当前用户可用能力 | +| `features[].feature` | string | 能力类型,见 0.4.1 | +| `features[].enabled` | boolean | 是否可用 | +| `features[].entry_title` | string | 前端入口名称 | +| `features[].daily_limit` | number | 单用户每日可用次数,`0` 表示不限制 | +| `features[].remaining_today` | number | 今日剩余次数 | +| `features[].max_input_chars` | number | 输入最大字符数 | +| `features[].max_file_size_mb` | number | 文件大小限制,非文件能力为 `0` | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "enabled": true, + "features": [ + { + "feature": "smart_document", + "enabled": true, + "entry_title": "智能文档", + "daily_limit": 20, + "remaining_today": 18, + "max_input_chars": 1000, + "max_file_size_mb": 0 + }, + { + "feature": "smart_table", + "enabled": true, + "entry_title": "智能表格", + "daily_limit": 10, + "remaining_today": 9, + "max_input_chars": 100, + "max_file_size_mb": 20 + } + ] + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWB", + "server_time": "2026-09-25T08:30:15.123Z" +} +``` + +#### 对接重点 + +1. 首页“智能文档”“智能表格”是否展示,以 `features[].enabled` 为准。 +2. `remaining_today=0` 时入口可展示,但生成按钮置灰并提示次数已用完。 +3. 前端不要写死 AI 能力开关,方便后续按租户灰度。 + +### 1.2 获取工作台应用搜索默认页 + +#### 需求图片 + +工作台搜索页面 + +| 项 | 内容 | +| --- | --- | +| 状态 | `需改造` | +| 方法 | `GET /v1/workbench/apps` | +| 用途 | 获取搜索页默认展示的常用应用和全部应用,包含 AI 应用 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 读接口无需幂等 | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `include_ai` | boolean | 否 | 是否包含 AI 应用,默认 `true` | + +#### 入参示例 + +```http +GET /v1/workbench/apps?include_ai=true HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWC +``` + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `common_apps[]` | array | 常用应用列表 | +| `all_apps[]` | array | 全部应用列表 | +| `app_id` | string | 应用 ID | +| `name` | string | 应用名称 | +| `icon` | string | 图标标识或图片 URL | +| `route` | string | 前端路由 | +| `type` | string | `builtin`、`ai` | +| `enabled` | boolean | 是否可点击 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "common_apps": [ + {"app_id": "daily_report", "name": "日报", "icon": "daily", "route": "/workbench/daily", "type": "builtin", "enabled": true}, + {"app_id": "smart_document", "name": "智能文档", "icon": "ai_document", "route": "/workbench/ai/document", "type": "ai", "enabled": true}, + {"app_id": "smart_table", "name": "智能表格", "icon": "ai_table", "route": "/workbench/ai/table", "type": "ai", "enabled": true} + ], + "all_apps": [ + {"app_id": "organization", "name": "组织框架", "icon": "org", "route": "/workbench/org", "type": "builtin", "enabled": true}, + {"app_id": "meeting_notes", "name": "会议笔记", "icon": "meeting_note", "route": "/workbench/meeting-notes", "type": "builtin", "enabled": true} + ] + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWC" +} +``` + +#### 对接重点 + +1. `route` 由前端路由解释,后端只返回稳定应用 ID 和显示名称。 +2. AI 应用是否出现在常用应用中,可由服务端按租户配置控制。 + +### 1.3 搜索工作台应用 + +#### 需求图片 + +工作台搜索结果 +工作台搜索无结果 + +| 项 | 内容 | +| --- | --- | +| 状态 | `需改造` | +| 方法 | `GET /v1/workbench/apps/search` | +| 用途 | 按关键字搜索工作台应用,返回可跳转应用结果 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 读接口无需幂等 | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `q` | string | 是 | 搜索关键词,最长 50 字符 | +| `include_ai` | boolean | 否 | 是否搜索 AI 应用,默认 `true` | + +#### 入参示例 + +```http +GET /v1/workbench/apps/search?q=%E7%BB%84%E7%BB%87&include_ai=true HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWD +``` + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `items[]` | array | 匹配的应用 | +| `matched_text` | string | 命中的展示文本 | +| `highlight_ranges[]` | array | 高亮范围,可选 | +| `has_more` | boolean | 是否还有更多结果 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "items": [ + { + "app_id": "organization", + "name": "组织框架", + "matched_text": "组织框架", + "icon": "org", + "route": "/workbench/org", + "type": "builtin", + "enabled": true, + "highlight_ranges": [{"start": 0, "end": 2}] + } + ], + "has_more": false + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWD" +} +``` + +#### 对接重点 + +1. 无结果时返回 `items: []`,前端展示“暂无匹配结果”。 +2. 搜索只返回当前用户有权限访问的应用。 +3. 前端不要基于本地固定列表搜索,否则 AI 应用灰度会不一致。 + +## 2. 智能文档接口 + +本章对应智能文档输入页、生成结果页和文档记录页。智能文档是异步任务,生成成功后前端展示结果;用户点击“保存文档”后才进入文档记录。 + +### 2.1 创建智能文档生成任务 + +#### 需求图片 + +智能文档输入 +智能文档生成结果 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `POST /v1/workbench/ai/documents/generate` | +| 用途 | 根据用户输入生成结构化文档 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 必须携带 `Idempotency-Key` | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `source_text` | string | 是 | 用户输入的核心内容,最长 1000 字 | +| `title_hint` | string | 否 | 标题提示,最长 80 字 | +| `document_type` | string | 否 | 文档类型,默认 `meeting_minutes`;可选 `meeting_minutes`、`plan`、`summary`、`general` | +| `language` | string | 否 | 语言,默认 `zh-CN` | + +#### 入参示例 + +```http +POST /v1/workbench/ai/documents/generate HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWE +Idempotency-Key: ai_doc_01K8ZC3FZ6BTX9Z1SK8HV7QYWE +Content-Type: application/json + +{ + "source_text": "今天讨论新项目排期,确认本周完成需求评审,下周启动开发,风险是接口字段还未冻结。", + "title_hint": "新项目沟通纪要", + "document_type": "meeting_minutes", + "language": "zh-CN" +} +``` + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `task_id` | string | AI 任务 ID | +| `status` | string | 任务状态 | +| `polling_interval_ms` | number | 建议轮询间隔 | +| `expires_at` | string | 未保存结果过期时间 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_7R8mX5Nf4D2a", + "status": "queued", + "polling_interval_ms": 1500, + "expires_at": "2026-09-25T10:30:15.123Z" + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWE" +} +``` + +#### 对接重点 + +1. 前端创建任务后跳转或停留在结果页 loading 状态,通过 2.2 查询任务。 +2. 输入为空、超过 1000 字或命中内容安全,必须返回明确错误码。 +3. 重新生成可再次调用本接口,并传新的幂等键。 + +### 2.2 查询 AI 任务详情 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `GET /v1/workbench/ai/tasks/{task_id}` | +| 用途 | 查询智能文档、智能表格等异步任务进度和结果 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 读接口无需幂等 | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `task_id` | string | 是 | 路径参数 | + +#### 入参示例 + +```http +GET /v1/workbench/ai/tasks/aitask_7R8mX5Nf4D2a HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWF +``` + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `task_id` | string | AI 任务 ID | +| `feature` | string | AI 能力类型 | +| `status` | string | 任务状态 | +| `progress` | number | 进度,0~100 | +| `steps[]` | array | 表格类任务步骤,智能文档可为空 | +| `result` | object | 任务结果,成功后返回 | +| `error` | object | 失败原因,失败后返回 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_7R8mX5Nf4D2a", + "feature": "smart_document", + "status": "succeeded", + "progress": 100, + "steps": [], + "result": { + "title": "新项目沟通纪要", + "content_markdown": "## 一、会议背景\n今天讨论新项目排期...\n\n## 二、关键结论\n- 本周完成需求评审\n- 下周启动开发", + "sections": [ + {"heading": "会议背景", "content": "今天讨论新项目排期。"}, + {"heading": "关键结论", "content": "本周完成需求评审,下周启动开发。"} + ], + "source_excerpt": "今天讨论新项目排期...", + "can_save": true + }, + "error": null + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWF" +} +``` + +#### 对接重点 + +1. `status=succeeded` 且 `result.can_save=true` 时前端展示“保存文档”。 +2. `content_markdown` 是主展示字段;`sections` 用于后续结构化编辑或导出。 +3. `status=failed` 时返回 `error.reason` 和 `error.message`,前端展示重试。 + +### 2.3 保存智能文档 + +#### 需求图片 + +智能文档编辑保存 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `POST /v1/workbench/ai/documents` | +| 用途 | 将生成结果保存为文档记录 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 必须携带 `Idempotency-Key` | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `task_id` | string | 是 | 生成任务 ID | +| `title` | string | 是 | 保存标题,最长 100 字 | +| `content_markdown` | string | 是 | 保存内容 | +| `content_plain` | string | 否 | 纯文本内容,用于搜索 | + +#### 入参示例 + +```http +POST /v1/workbench/ai/documents HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWG +Idempotency-Key: ai_doc_save_01K8ZC3FZ6BTX9Z1SK8HV7QYWG +Content-Type: application/json + +{ + "task_id": "aitask_7R8mX5Nf4D2a", + "title": "新项目沟通纪要", + "content_markdown": "## 一、会议背景\n今天讨论新项目排期...", + "content_plain": "今天讨论新项目排期,本周完成需求评审。" +} +``` + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `record_id` | string | 文档记录 ID | +| `title` | string | 文档标题 | +| `created_at` | string | 保存时间 | +| `updated_at` | string | 更新时间 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "record_id": "airec_doc_5Jv8mVb21Q", + "title": "新项目沟通纪要", + "created_at": "2026-09-25T08:35:00.000Z", + "updated_at": "2026-09-25T08:35:00.000Z" + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWG" +} +``` + +#### 对接重点 + +1. 同一个 `task_id` 可以保存一次;重复保存同一幂等键返回同一 `record_id`。 +2. 前端编辑后的内容以保存接口入参为准,后端不再重新调用 AI。 +3. 未保存结果过期后保存返回 `RESOURCE_NOT_FOUND` 或 `AI_TASK_FAILED`。 + +### 2.4 文档记录列表 + +#### 需求图片 + +文档记录 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `GET /v1/workbench/ai/documents` | +| 用途 | 查询当前用户保存的智能文档记录 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 读接口无需幂等 | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `query` | string | 否 | 标题或内容搜索 | +| `cursor` | string | 否 | 分页游标 | +| `limit` | number | 否 | 默认 20,最大 50 | + +#### 入参示例 + +```http +GET /v1/workbench/ai/documents?query=%E9%A1%B9%E7%9B%AE&cursor=&limit=20 HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWH +``` + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `items[]` | array | 文档记录 | +| `record_id` | string | 记录 ID | +| `title` | string | 标题 | +| `summary` | string | 摘要 | +| `document_type` | string | 文档类型 | +| `status` | string | 固定 `succeeded` | +| `created_at` | string | 创建时间 | +| `updated_at` | string | 更新时间 | +| `next_cursor` | string | 下一页游标 | +| `has_more` | boolean | 是否还有更多 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "items": [ + { + "record_id": "airec_doc_5Jv8mVb21Q", + "title": "新项目沟通纪要", + "summary": "确认本周完成需求评审,下周启动开发。", + "document_type": "meeting_minutes", + "status": "succeeded", + "created_at": "2026-09-25T08:35:00.000Z", + "updated_at": "2026-09-25T08:35:00.000Z" + } + ], + "next_cursor": "", + "has_more": false + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWH" +} +``` + +#### 对接重点 + +1. 文档记录只返回当前用户保存的数据。 +2. 搜索建议同时匹配标题和 `content_plain`。 + +### 2.5 文档记录详情 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `GET /v1/workbench/ai/documents/{record_id}` | +| 用途 | 获取智能文档保存内容 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 读接口无需幂等 | + +#### 入参示例 + +```http +GET /v1/workbench/ai/documents/airec_doc_5Jv8mVb21Q HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWI +``` + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "record_id": "airec_doc_5Jv8mVb21Q", + "title": "新项目沟通纪要", + "document_type": "meeting_minutes", + "content_markdown": "## 一、会议背景\n今天讨论新项目排期...", + "source_excerpt": "今天讨论新项目排期...", + "created_at": "2026-09-25T08:35:00.000Z", + "updated_at": "2026-09-25T08:35:00.000Z" + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWI" +} +``` + +## 3. 智能表格与报告解读接口 + +本章对应智能表格首页、选择文件、上传成功、分析进度、报告解读和解读记录。文件上传先走 IM 文件服务,再创建表格解读任务。 + +### 3.1 上传智能表格源文件 + +#### 需求图片 + +智能表格 +选择文件 +文件上传成功 xlsx +文件上传成功 xls +文件上传成功 csv + +| 项 | 内容 | +| --- | --- | +| 状态 | `复用` | +| 方法 | `POST /v0/file/u/` | +| 用途 | 上传智能表格待分析文件,返回 `file_id` | +| 鉴权 | 沿用 IM 文件服务鉴权 | +| 幂等 | 文件服务按既有规则处理 | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `id` | string | 是 | 客户端文件上传 ID | +| `purpose` | string | 是 | 固定 `ai_table_source` | +| `file` | file | 是 | 支持 `.xlsx`、`.xls`、`.csv` | + +#### 入参示例 + +```http +POST /v0/file/u/ HTTP/1.1 +X-MeetingIM-APIKey: +X-MeetingIM-Auth: token +X-IM-Schema: 1 +X-Tenant-Code: system +Content-Type: multipart/form-data + +id=file_01K8ZC3FZ6BTX9Z1SK8HV7QYWJ +purpose=ai_table_source +file=@sales_august.xlsx +``` + +#### 出参示例 + +```json +{ + "ctrl": { + "id": "file_01K8ZC3FZ6BTX9Z1SK8HV7QYWJ", + "code": 200, + "text": "ok", + "params": { + "file_id": "fil_8xMb92qK7L", + "name": "sales_august.xlsx", + "size": 2726297, + "mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + } +} +``` + +#### 对接重点 + +1. 文件服务响应结构沿用 IM `ctrl`,不是 workbench 统一 `code/data` 包装。 +2. 前端需在本地先校验扩展名和大小,后端仍必须二次校验。 +3. `file_id` 只作为后续创建 AI 表格任务的输入,不直接暴露给其他用户。 + +### 3.2 创建表格解读任务 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `POST /v1/workbench/ai/table-analyses` | +| 用途 | 根据上传文件和报表主题创建智能表格解读任务 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 必须携带 `Idempotency-Key` | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `file_id` | string | 是 | 文件服务返回的文件 ID | +| `file_name` | string | 是 | 原始文件名 | +| `file_type` | string | 是 | `xlsx`、`xls`、`csv` | +| `file_size` | number | 是 | 文件大小,单位 byte | +| `topic` | string | 是 | 报表主题,最长 100 字 | +| `sheet_name` | string | 否 | 指定分析工作表;为空时服务端自动选择 | +| `language` | string | 否 | 默认 `zh-CN` | + +#### 入参示例 + +```http +POST /v1/workbench/ai/table-analyses HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWK +Idempotency-Key: ai_table_01K8ZC3FZ6BTX9Z1SK8HV7QYWK +Content-Type: application/json + +{ + "file_id": "fil_8xMb92qK7L", + "file_name": "sales_august.xlsx", + "file_type": "xlsx", + "file_size": 2726297, + "topic": "8月销售数据", + "sheet_name": "", + "language": "zh-CN" +} +``` + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `task_id` | string | AI 任务 ID | +| `record_id` | string | 预生成记录 ID;任务成功后可打开详情 | +| `status` | string | 任务状态 | +| `polling_interval_ms` | number | 建议轮询间隔 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_tbl_2Fh9aQx73K", + "record_id": "airec_tbl_6LP0nM9s3B", + "status": "queued", + "polling_interval_ms": 1500 + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWK" +} +``` + +#### 对接重点 + +1. 表格解读成功后默认生成记录,无需用户另点保存。 +2. 文件不存在、类型不支持、超过大小限制必须在创建任务时拦截。 +3. 同一用户可同时运行的表格任务建议限制为 1~3 个,超过返回 `RATE_LIMITED`。 + +### 3.3 查询表格解读进度 + +#### 需求图片 + +智能解读报告处理中 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `GET /v1/workbench/ai/tasks/{task_id}` | +| 用途 | 查询表格解读任务进度 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 读接口无需幂等 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_tbl_2Fh9aQx73K", + "feature": "smart_table", + "status": "processing", + "progress": 68, + "steps": [ + {"step": "read_file", "title": "读取表格数据", "status": "succeeded"}, + {"step": "detect_metrics", "title": "识别核心指标", "status": "succeeded"}, + {"step": "analyze_trends", "title": "分析趋势变化", "status": "processing"}, + {"step": "generate_report", "title": "生成解读报告", "status": "pending"} + ], + "result": null, + "error": null + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWL" +} +``` + +#### 对接重点 + +1. 前端进度条使用 `progress`,步骤列表使用 `steps`。 +2. 服务端不保证每次轮询都有进度变化,但状态必须单向推进。 +3. 任务成功后 `result.record_id` 指向报告详情。 + +### 3.4 取消表格解读任务 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `POST /v1/workbench/ai/tasks/{task_id}/cancel` | +| 用途 | 取消正在排队或处理中的 AI 任务 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 必须携带 `Idempotency-Key` | + +#### 入参示例 + +```http +POST /v1/workbench/ai/tasks/aitask_tbl_2Fh9aQx73K/cancel HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWM +Idempotency-Key: ai_cancel_01K8ZC3FZ6BTX9Z1SK8HV7QYWM +``` + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_tbl_2Fh9aQx73K", + "status": "canceled", + "canceled_at": "2026-09-25T08:40:00.000Z" + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWM" +} +``` + +### 3.5 获取报告解读详情 + +#### 需求图片 + +报告解读 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `GET /v1/workbench/ai/table-analyses/{record_id}` | +| 用途 | 获取智能表格解读报告详情 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 读接口无需幂等 | + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `record_id` | string | 报告记录 ID | +| `title` | string | 报告标题 | +| `file` | object | 源文件信息 | +| `metrics[]` | array | 核心指标 | +| `preview` | object | 数据预览 | +| `alerts[]` | array | 异常提醒 | +| `interpretation` | string | 核心解读 | +| `export_options[]` | array | 可导出格式 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "record_id": "airec_tbl_6LP0nM9s3B", + "title": "8月销售数据", + "file": { + "file_id": "fil_8xMb92qK7L", + "file_name": "sales_august.xlsx", + "file_type": "xlsx", + "file_size": 2726297 + }, + "metrics": [ + {"name": "销售额", "value": "128.6万", "change_text": "+12.3%", "trend": "up"}, + {"name": "订单量", "value": "3,842", "change_text": "+8.7%", "trend": "up"}, + {"name": "退款率", "value": "2.1%", "change_text": "-0.4%", "trend": "down"} + ], + "preview": { + "columns": ["日期", "销售额", "订单量", "退款率"], + "rows": [ + ["8/1", "42,300", "126", "2.3%"], + ["8/2", "48,120", "139", "2.0%"] + ], + "total_rows": 31 + }, + "alerts": [ + {"level": "warning", "title": "8月15日订单量异常下降", "description": "订单量较前一日下降 18%,建议核对促销和渠道数据。"} + ], + "interpretation": "8月整体销售额较上月增长,订单量同步提升,退款率下降,说明转化质量改善。", + "export_options": ["docx", "pdf"], + "created_at": "2026-09-25T08:42:00.000Z" + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWN" +} +``` + +#### 对接重点 + +1. 核心指标、异常提醒、核心解读都由后端生成并持久化,前端只负责展示。 +2. `preview.rows` 只返回前 N 行用于展示,不返回完整表格数据。 +3. 前端展示“由 AI 生成,请核对后使用”的风险提示。 + +### 3.6 解读记录列表 + +#### 需求图片 + +解读记录 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `GET /v1/workbench/ai/table-analyses` | +| 用途 | 查询当前用户的表格解读记录 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 读接口无需幂等 | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `query` | string | 否 | 按标题或文件名搜索 | +| `cursor` | string | 否 | 分页游标 | +| `limit` | number | 否 | 默认 20,最大 50 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "items": [ + { + "record_id": "airec_tbl_6LP0nM9s3B", + "title": "8月销售数据", + "file_name": "sales_august.xlsx", + "file_type": "xlsx", + "status": "succeeded", + "created_at": "2026-09-25T08:42:00.000Z" + } + ], + "next_cursor": "", + "has_more": false + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWO" +} +``` + +### 3.7 导出解读报告 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `POST /v1/workbench/ai/table-analyses/{record_id}/export` | +| 用途 | 将解读报告导出为文件 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 必须携带 `Idempotency-Key` | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `format` | string | 是 | `docx` 或 `pdf` | + +#### 入参示例 + +```http +POST /v1/workbench/ai/table-analyses/airec_tbl_6LP0nM9s3B/export HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWP +Idempotency-Key: ai_export_01K8ZC3FZ6BTX9Z1SK8HV7QYWP +Content-Type: application/json + +{"format": "docx"} +``` + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "file_id": "fil_export_9pLm2Q", + "file_name": "8月销售数据-解读报告.docx", + "download_url": "https://api.118im.com/v0/file/s/fil_export_9pLm2Q", + "expires_at": "2026-09-26T08:42:00.000Z" + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWP" +} +``` + +## 4. AI 美文与日报/月报接入 + +AI 美文是同步接口,用于优化短文案。日报、月报仍使用原提交接口,AI 只负责生成或优化输入框内容。 + +### 4.1 AI 美文 + +#### 需求图片 + +AI美文 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `POST /v1/workbench/ai/text/polish` | +| 用途 | 对原文进行润色,返回可直接使用的优化文案 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 必须携带 `Idempotency-Key` | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `source_text` | string | 是 | 原文,最长 300 字 | +| `scene` | string | 是 | `general`、`daily_report`、`monthly_report` | +| `tone` | string | 否 | 语气,默认 `professional` | +| `max_output_chars` | number | 否 | 输出最大长度,默认 300 | + +#### 入参示例 + +```http +POST /v1/workbench/ai/text/polish HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWQ +Idempotency-Key: ai_polish_01K8ZC3FZ6BTX9Z1SK8HV7QYWQ +Content-Type: application/json + +{ + "source_text": "今天完成接口联调,处理了几个bug,明天继续测试。", + "scene": "daily_report", + "tone": "professional", + "max_output_chars": 300 +} +``` + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `polished_text` | string | 润色后的文案 | +| `source_text` | string | 原文回显 | +| `usage` | object | 本次用量信息 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "source_text": "今天完成接口联调,处理了几个bug,明天继续测试。", + "polished_text": "今日已完成接口联调,并修复联调过程中发现的多个问题。明日将继续推进测试验证,确保相关功能稳定可用。", + "usage": { + "remaining_today": 17 + } + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWQ" +} +``` + +#### 对接重点 + +1. 前端“重新生成”再次调用本接口,使用新的幂等键。 +2. 前端“使用文案”只把 `polished_text` 回填到原业务输入框,不自动提交日报或月报。 +3. 输出仍允许用户编辑,业务提交以用户最终输入为准。 + +### 4.2 日报 AI 接入 + +#### 需求图片 + +日报 AI 接入 + +| 项 | 内容 | +| --- | --- | +| 状态 | `复用 + 需改造` | +| AI 接口 | `POST /v1/workbench/ai/text/polish` | +| 业务接口 | 沿用日报新增/编辑接口 | +| 用途 | 在日报“今日工作总结”等输入框中接入 AI 美文 | +| 鉴权 | 需要 IM 登录态 | + +#### 对接流程 + +1. 用户在日报输入框填写原文。 +2. 前端点击“AI 美文”,调用 4.1,`scene=daily_report`。 +3. 前端展示 AI 美文弹层,用户可重新生成或使用文案。 +4. 用户点击“使用文案”后,前端把 `polished_text` 回填到日报字段。 +5. 用户点击“提交”后,仍调用原日报提交接口。 + +#### 入参示例 + +```json +{ + "source_text": "完成工作台接口联调,修复会议笔记字段问题。", + "scene": "daily_report", + "tone": "professional", + "max_output_chars": 300 +} +``` + +#### 对接重点 + +1. AI 不改变日报是否可提交、是否冲突、是否重复的业务规则。 +2. 日报保存成功后不需要额外保存 AI 记录;如后端需要审计,可写入用量日志。 + +### 4.3 月报 AI 接入 + +#### 需求图片 + +月报 AI 接入 + +| 项 | 内容 | +| --- | --- | +| 状态 | `复用 + 需改造` | +| AI 接口 | `POST /v1/workbench/ai/text/polish` | +| 业务接口 | 沿用月报新增/编辑接口 | +| 用途 | 在月报“本月工作总结”“其他事项”等输入框中接入 AI 美文 | +| 鉴权 | 需要 IM 登录态 | + +#### 对接流程 + +1. 用户在月报输入框填写原文。 +2. 前端点击对应输入框附近的“AI 美文”。 +3. 前端调用 4.1,`scene=monthly_report`。 +4. 用户确认使用后,前端只回填当前字段。 +5. 月报发布仍调用原月报接口。 + +#### 对接重点 + +1. 月报字段上限以月报业务接口为准;AI 输入建议最长 1000 字,输出不超过原字段限制。 +2. “发布中”状态由原月报接口控制,AI 美文接口不返回发布状态。 + +## 5. 会议笔记 AI 总结接口 + +会议 AI 总结挂在会议笔记详情下,使用会议附件、会议标题、参与人等已有会议上下文生成摘要。会议列表、会议笔记列表和文件上传仍沿用既有工作台会议接口。 + +### 5.1 创建会议 AI 总结任务 + +#### 需求图片 + +会议笔记 AI 总结 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增 + 需改造` | +| 方法 | `POST /v1/workbench/meetings/{meeting_id}/ai-summary` | +| 用途 | 对会议笔记附件或会议内容生成 AI 总结 | +| 鉴权 | 需要 IM 登录态和会议笔记查看权限 | +| 幂等 | 必须携带 `Idempotency-Key` | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `meeting_id` | string | 是 | 路径参数 | +| `file_ids[]` | array | 否 | 指定参与总结的会议附件;为空时使用会议下可读附件 | +| `summary_type` | string | 否 | 默认 `standard` | +| `force_regenerate` | boolean | 否 | 已存在总结时是否重新生成,默认 `false` | + +#### 入参示例 + +```http +POST /v1/workbench/meetings/mtg_7YUHoLTqrl4ZO_G6/ai-summary HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWR +Idempotency-Key: ai_meeting_01K8ZC3FZ6BTX9Z1SK8HV7QYWR +Content-Type: application/json + +{ + "file_ids": ["fil_note_1", "fil_note_2"], + "summary_type": "standard", + "force_regenerate": false +} +``` + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_mtg_3Rn8qU2b6X", + "meeting_id": "mtg_7YUHoLTqrl4ZO_G6", + "status": "queued", + "polling_interval_ms": 1500 + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWR" +} +``` + +#### 对接重点 + +1. 无会议查看权限返回 `FORBIDDEN`。 +2. 会议没有可分析内容时返回 `VALIDATION_FAILED`,details 中标明 `missing_source=true`。 +3. 已有总结且 `force_regenerate=false` 时可直接返回现有总结或返回 `CONFLICT`,建议返回现有总结以减少前端分支。 + +### 5.2 查询会议 AI 总结 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `GET /v1/workbench/meetings/{meeting_id}/ai-summary` | +| 用途 | 获取会议 AI 总结内容 | +| 鉴权 | 需要 IM 登录态和会议笔记查看权限 | +| 幂等 | 读接口无需幂等 | + +#### 出参说明 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `meeting_id` | string | 会议 ID | +| `summary_id` | string | 总结 ID | +| `status` | string | `empty`、`processing`、`succeeded`、`failed` | +| `conclusions[]` | array | 会议结论 | +| `decisions[]` | array | 关键决策 | +| `action_items[]` | array | 后续安排 | +| `editable` | boolean | 当前用户是否可编辑 | +| `updated_at` | string | 更新时间 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "meeting_id": "mtg_7YUHoLTqrl4ZO_G6", + "summary_id": "aisum_7Kp2N8aV", + "status": "succeeded", + "conclusions": [ + "工作台 AI 首版接入智能文档、智能表格和 AI 美文。", + "日报、月报沿用原业务提交接口。" + ], + "decisions": [ + "表格解读采用异步任务,成功后默认生成记录。", + "会议 AI 总结挂载在会议笔记详情页。" + ], + "action_items": [ + { + "owner_user_id": "usrABAAnqj42BU", + "owner_display_name": "张三", + "content": "补充 AI 任务表结构设计", + "due_at": "2026-09-26T10:00:00.000Z", + "status": "pending" + } + ], + "editable": true, + "updated_at": "2026-09-25T08:50:00.000Z" + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWS" +} +``` + +#### 对接重点 + +1. `status=empty` 时前端展示空态和“生成 AI 总结”入口。 +2. `status=processing` 时前端可复用任务轮询,也可定时刷新本接口。 +3. `action_items.owner_user_id` 必须是字符串,不允许返回数字。 + +### 5.3 编辑会议 AI 总结 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `PUT /v1/workbench/meetings/{meeting_id}/ai-summary` | +| 用途 | 保存用户手动编辑后的 AI 总结 | +| 鉴权 | 需要会议笔记编辑权限 | +| 幂等 | 必须携带 `Idempotency-Key` | + +#### 入参示例 + +```http +PUT /v1/workbench/meetings/mtg_7YUHoLTqrl4ZO_G6/ai-summary HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWT +Idempotency-Key: ai_meeting_save_01K8ZC3FZ6BTX9Z1SK8HV7QYWT +Content-Type: application/json + +{ + "summary_id": "aisum_7Kp2N8aV", + "conclusions": ["工作台 AI 首版接入三类能力。"], + "decisions": ["会议 AI 总结挂载在会议笔记详情页。"], + "action_items": [ + { + "owner_user_id": "usrABAAnqj42BU", + "content": "补充 AI 任务表结构设计", + "due_at": "2026-09-26T10:00:00.000Z", + "status": "pending" + } + ] +} +``` + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "meeting_id": "mtg_7YUHoLTqrl4ZO_G6", + "summary_id": "aisum_7Kp2N8aV", + "updated_at": "2026-09-25T09:00:00.000Z" + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWT" +} +``` + +#### 对接重点 + +1. 编辑保存后内容不再标记为纯 AI 原文,前端仍可展示“由 AI 自动生成,如有不准确可手动编辑”。 +2. 会议总结编辑权限建议复用会议笔记 `can_replace` 或 `can_upload` 权限,最终以后端权限判断为准。 + +## 6. AI 记录与任务通用接口 + +### 6.1 通用 AI 记录列表 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `GET /v1/workbench/ai/records` | +| 用途 | 按类型查询 AI 记录,供后续统一记录中心使用 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 读接口无需幂等 | + +#### 入参说明 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `type` | string | 否 | `smart_document`、`smart_table` | +| `query` | string | 否 | 搜索词 | +| `cursor` | string | 否 | 分页游标 | +| `limit` | number | 否 | 默认 20,最大 50 | + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "items": [ + { + "record_id": "airec_doc_5Jv8mVb21Q", + "type": "smart_document", + "title": "新项目沟通纪要", + "summary": "确认本周完成需求评审,下周启动开发。", + "status": "succeeded", + "created_at": "2026-09-25T08:35:00.000Z", + "route": "/workbench/ai/document/airec_doc_5Jv8mVb21Q" + }, + { + "record_id": "airec_tbl_6LP0nM9s3B", + "type": "smart_table", + "title": "8月销售数据", + "summary": "销售额增长 12.3%,退款率下降。", + "status": "succeeded", + "created_at": "2026-09-25T08:42:00.000Z", + "route": "/workbench/ai/table/airec_tbl_6LP0nM9s3B" + } + ], + "next_cursor": "", + "has_more": false + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWU" +} +``` + +### 6.2 删除 AI 记录 + +| 项 | 内容 | +| --- | --- | +| 状态 | `新增` | +| 方法 | `DELETE /v1/workbench/ai/records/{record_id}` | +| 用途 | 删除当前用户自己的 AI 记录 | +| 鉴权 | 需要 IM 登录态 | +| 幂等 | 删除接口幂等,同一记录重复删除返回成功 | + +#### 入参示例 + +```http +DELETE /v1/workbench/ai/records/airec_doc_5Jv8mVb21Q HTTP/1.1 +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: system +X-Request-ID: 01K8ZC3FZ6BTX9Z1SK8HV7QYWV +``` + +#### 出参示例 + +```json +{ + "code": 0, + "data": { + "record_id": "airec_doc_5Jv8mVb21Q", + "deleted": true + }, + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWV" +} +``` + +### 6.3 AI 任务事件 + +| 项 | 内容 | +| --- | --- | +| 状态 | `可后续扩展` | +| 事件 | `workbench.ai.task_updated` | +| 用途 | 服务端主动推送 AI 任务状态变化,减少轮询 | +| 鉴权 | 复用 IM WebSocket 登录态 | + +#### 事件示例 + +```json +{ + "topic": "workbench.ai.task_updated", + "data": { + "task_id": "aitask_tbl_2Fh9aQx73K", + "feature": "smart_table", + "status": "succeeded", + "progress": 100, + "record_id": "airec_tbl_6LP0nM9s3B", + "updated_at": "2026-09-25T08:42:00.000Z" + } +} +``` + +#### 对接重点 + +1. 首版建议优先使用 HTTP 轮询,WebSocket 事件作为后续优化。 +2. 即使接入事件,前端仍需保留轮询兜底。 + +## 7. 对接重难点 + +### 7.1 IM 登录与 workbench AI 边界 + +AI 接口只接受 IM 登录态,不单独做 AI 登录。租户、用户、部门权限全部由 IM Token 和工作台权限体系派生。前端不要在请求体中传用户身份字段。 + +### 7.2 异步任务与用户体验 + +智能文档、智能表格、会议 AI 总结都可能耗时。前端必须处理 `queued`、`processing`、`succeeded`、`failed`、`canceled`,并在用户离开页面后可通过记录或详情恢复结果。 + +### 7.3 文件服务与 AI 服务衔接 + +智能表格先上传文件,再创建解读任务。后端创建任务时必须校验 `file_id` 属于当前租户和当前用户,并检查文件类型、大小、有效期。AI 服务不得直接信任前端传入的 `file_name`、`file_type`。 + +### 7.4 字段类型一致性 + +所有用户 ID、会议 ID、记录 ID、任务 ID、文件 ID 都是字符串。尤其是 `owner_user_id`、`attendee_user_ids`、`recorder_user_id`、`participant.user_id` 不允许使用 number。 + +### 7.5 内容安全与合规 + +AI 输入和输出都需要内容安全检查。命中安全规则时返回 `AI_CONTENT_REJECTED`;前端展示温和提示,不展示供应商原始错误。日志中不得打印完整用户输入、完整模型输出、Token、文件下载地址和内部密钥。 + +### 7.6 用量限制与灰度开关 + +AI 能力需要按租户和用户配置开关、每日次数、并发数和文件大小。前端展示入口前先读取 1.1;后端接口仍必须强校验,不能只依赖前端隐藏入口。 + +### 7.7 会议 AI 总结权限 + +会议 AI 总结不能只判断会议 ID 存在。生成、查看、编辑必须分别判断会议笔记权限;附件参与总结前也要校验附件属于该会议且当前用户可读。 + +## 8. 接口清单 + +| 模块 | 接口 | 方法 | 状态 | 说明 | +| --- | --- | --- | --- | --- | +| AI 能力 | `/v1/workbench/ai/capabilities` | GET | 新增 | 获取 AI 能力开关和用量 | +| 工作台应用 | `/v1/workbench/apps` | GET | 需改造 | 默认应用列表包含 AI 应用 | +| 工作台搜索 | `/v1/workbench/apps/search` | GET | 需改造 | 搜索工作台应用 | +| 智能文档 | `/v1/workbench/ai/documents/generate` | POST | 新增 | 创建文档生成任务 | +| AI 任务 | `/v1/workbench/ai/tasks/{task_id}` | GET | 新增 | 查询任务进度与结果 | +| 智能文档 | `/v1/workbench/ai/documents` | POST | 新增 | 保存生成文档 | +| 智能文档 | `/v1/workbench/ai/documents` | GET | 新增 | 文档记录列表 | +| 智能文档 | `/v1/workbench/ai/documents/{record_id}` | GET | 新增 | 文档记录详情 | +| 文件上传 | `/v0/file/u/` | POST | 复用 | 上传表格源文件 | +| 智能表格 | `/v1/workbench/ai/table-analyses` | POST | 新增 | 创建表格解读任务 | +| AI 任务 | `/v1/workbench/ai/tasks/{task_id}/cancel` | POST | 新增 | 取消 AI 任务 | +| 智能表格 | `/v1/workbench/ai/table-analyses/{record_id}` | GET | 新增 | 报告解读详情 | +| 智能表格 | `/v1/workbench/ai/table-analyses` | GET | 新增 | 解读记录列表 | +| 智能表格 | `/v1/workbench/ai/table-analyses/{record_id}/export` | POST | 新增 | 导出解读报告 | +| AI 美文 | `/v1/workbench/ai/text/polish` | POST | 新增 | 文案润色 | +| 会议总结 | `/v1/workbench/meetings/{meeting_id}/ai-summary` | POST | 新增 | 创建会议 AI 总结 | +| 会议总结 | `/v1/workbench/meetings/{meeting_id}/ai-summary` | GET | 新增 | 查询会议 AI 总结 | +| 会议总结 | `/v1/workbench/meetings/{meeting_id}/ai-summary` | PUT | 新增 | 编辑会议 AI 总结 | +| AI 记录 | `/v1/workbench/ai/records` | GET | 新增 | 通用记录列表 | +| AI 记录 | `/v1/workbench/ai/records/{record_id}` | DELETE | 新增 | 删除 AI 记录 | + +## 9. 前后端联调建议 + +1. 先联调 `capabilities`、应用列表和搜索,确保入口展示一致。 +2. 再联调 AI 美文,因为它是同步接口,最适合验证鉴权、租户、用量和错误包。 +3. 智能文档联调重点是任务轮询、保存文档、文档记录。 +4. 智能表格联调重点是文件上传、文件归属校验、任务步骤、报告详情和导出。 +5. 会议 AI 总结最后联调,重点验证会议权限、附件权限和编辑保存。 +6. 每个写接口都要测试同一 `Idempotency-Key` 重试、不同请求体冲突、Token 过期、跨租户访问、无权限访问。 diff --git a/docs/IM工作台AI后端概要设计文档.md b/docs/IM工作台AI后端概要设计文档.md new file mode 100644 index 0000000..8e7f73d --- /dev/null +++ b/docs/IM工作台AI后端概要设计文档.md @@ -0,0 +1,1289 @@ +# IM 工作台 AI 后端概要设计文档 + +> 版本:V1.0 +> 日期:2026-09-25 +> 关联文档:[IM 工作台 AI 接入需求文档](./IM工作台AI接入需求文档.md) · [IM 工作台 AI 前后端对接文档](./IM工作台AI前后端对接文档.md) · [IM 前后端对接文档](./IM-前后端对接文档.md) +> DeepSeek 依据:[DeepSeek 首次调用 API](https://api-docs.deepseek.com/zh-cn/) · [DeepSeek Chat Completions API](https://api-docs.deepseek.com/zh-cn/api/create-chat-completion/) +> 说明:本文描述 IM 工作台 AI 能力的后端概要设计,覆盖智能文档、智能表格、AI 美文、日报/月报 AI 接入、会议笔记 AI 总结、AI 任务、AI 记录、DeepSeek 接入、配置、异步处理、数据表、安全和发布回滚。 + +--- + +## 一、设计目标 + +IM 工作台在现有日报、月报、会议、文件和组织能力基础上新增 AI 能力。后端需要在不破坏原 workbench 业务接口的前提下,提供统一的 AI 能力接入层、任务调度层、记录存储层和 DeepSeek Provider 适配层。 + +后端需要完成以下核心能力: + +1. **工作台 AI 入口**:按租户、用户、功能开关返回可用 AI 能力、用量限制和入口配置。 +2. **智能文档**:根据用户输入生成结构化文档,支持重新生成、编辑保存、记录查询。 +3. **智能表格**:接收已上传表格文件,解析数据、识别核心指标、生成报告解读,支持记录查询和导出。 +4. **AI 美文**:为普通文案、日报、月报提供同步润色能力,只回填业务表单,不替代原业务提交。 +5. **会议 AI 总结**:基于会议笔记附件和会议上下文生成结论、决策、后续安排,支持编辑保存。 +6. **DeepSeek 接入**:通过 DeepSeek OpenAI 兼容 Chat Completions API 接入模型,统一处理 prompt、超时、重试、错误映射、审计、脱敏和用量统计。 +7. **可靠性**:支持幂等、异步任务、失败重试、并发限制、租户隔离、内容安全和灰度开关。 + +### 设计原则 + +- AI 能力放在 `im_workbench` 服务内建设,登录、租户、用户、组织、文件、会议仍复用 IM 与 workbench 既有能力。 +- 所有 AI 业务表统一使用 `wb_ai_` 前缀。 +- 所有业务数据必须带 `tenant_id`、`tenant_code`、`user_id`;用户身份由 IM Token 解析,客户端不得传入并覆盖身份。 +- 所有对外 ID 统一按字符串返回,包括 `task_id`、`record_id`、`meeting_id`、`file_id`、`user_id`。 +- AI Provider 必须抽象为接口,首版实现 DeepSeek,后续可切换到其他模型供应商。 +- DeepSeek API Key、base_url、model、超时、并发、用量限制全部配置化,不写死在代码里。 +- AI 输入、输出、文件解析内容、prompt 和模型响应都可能包含敏感信息,日志只记录摘要、hash、长度、token 用量和错误码。 +- 同步接口只用于短文本 AI 美文;文档、表格、会议总结统一按异步任务处理。 +- AI 输出默认不作为最终事实,前端必须展示“由 AI 生成,请核对后使用”。 + +--- + +## 二、系统架构 + +### 2.1 服务边界 + +首版建议直接在 `im_workbench` 中新增 AI 模块,避免多服务联调成本。模块内部按 Provider、任务、记录、业务适配做清晰边界,后续可平滑拆成独立 AI Worker 服务。 + +```text +移动端 / Web + | + v +IM Gateway / Workbench API + | + +--------------------> IM Auth / Tenant + | Token、租户、用户上下文 + | + v +im_workbench + ├─ Workbench API AI HTTP 路由、参数校验、响应转换 + ├─ AI Application 智能文档、智能表格、AI 美文、会议总结用例 + ├─ AI Task Domain 任务状态机、幂等、进度、取消、重试 + ├─ AI Record Domain 文档记录、表格记录、会议总结记录 + ├─ AI Provider Adapter DeepSeek 适配、Prompt 编排、错误映射 + ├─ File Adapter IM 文件下载、表格解析、导出文件上传 + ├─ Meeting Adapter 会议、会议笔记、权限和附件 + ├─ Worker Kafka/Redis/MySQL 任务消费 + └─ MySQL / Redis / Kafka + | + +---------------> DeepSeek API + | Chat Completions + +---------------> IM File Service + | 文件上传、下载、导出 + +---------------> IM User/Org + 用户、租户、权限、组织信息 +``` + +### 2.2 推荐代码分层 + +```text +workbench/ai/ +├── api/ HTTP Handler、DTO、参数校验 +├── application/ +│ ├── capability/ AI 能力开关和配额 +│ ├── document/ 智能文档用例 +│ ├── table/ 智能表格用例 +│ ├── polish/ AI 美文用例 +│ └── meeting/ 会议 AI 总结用例 +├── domain/ +│ ├── task/ AI 任务状态机、幂等、取消、重试 +│ ├── record/ AI 记录模型 +│ ├── quota/ 用量限制、并发限制 +│ └── prompt/ Prompt 模板、变量、输出 Schema +├── repository/ MySQL/Redis 数据访问 +├── integration/ +│ ├── deepseek/ DeepSeek Provider 实现 +│ ├── file/ IM 文件服务与导出文件 +│ ├── meeting/ 会议和会议笔记权限 +│ ├── user/ 用户显示名、组织信息 +│ └── safety/ 内容安全检查 +├── worker/ AI 异步任务消费 +├── parser/ Excel/CSV 解析和采样 +├── exporter/ DOCX/PDF 导出 +└── migrations/ wb_ai_ 表结构和增量脚本 +``` + +目录名称可按实际项目框架调整,但 AI Provider、任务状态机、业务用例和基础设施适配层必须解耦,不能在 Handler 中直接拼 prompt 调 DeepSeek。 + +### 2.3 请求上下文 + +Gateway 或中间件鉴权成功后向 AI 模块传递可信上下文: + +| 字段 | 来源 | 用途 | +| --- | --- | --- | +| `request_id` | `X-Request-ID` 或服务端生成 | 全链路追踪 | +| `tenant_id` | Token/租户解析结果 | 数据隔离和联合索引 | +| `tenant_code` | `X-Tenant-Code`/Token | 日志、配置、文件服务路由 | +| `user_id` | Token | 当前操作用户 | +| `device_id` | Token/请求头 | 多端诊断和风控 | +| `api_key` | `X-MeetingIM-APIKey` | 复用 IM 客户端鉴权 | +| `locale` | 请求头/用户配置 | Prompt 语言和返回文案 | + +### 2.4 外部依赖 + +| 依赖 | 用途 | 失败策略 | +| --- | --- | --- | +| IM Auth/Tenant | Token、租户和用户上下文 | 鉴权失败直接拒绝 | +| IM User/Org | 用户名、组织、参与人展示 | 展示可降级为用户 ID,写权限不可绕过 | +| Workbench Meeting | 会议、会议笔记、附件权限 | 无权限直接拒绝 | +| IM File | 表格源文件下载、会议附件读取、导出文件上传 | 文件不可用则任务失败 | +| DeepSeek API | 文案生成、结构化总结和报告解读 | 超时/失败按策略重试,最终任务失败 | +| MySQL | 任务、记录、用量、幂等、审计 | 核心状态最终一致来源 | +| Redis | 短期配额、并发锁、任务进度缓存 | MySQL 兜底,Redis 失败不放宽限制 | +| Kafka | 异步任务分发 | 不可用时写入任务但保持 `queued`,恢复后补偿扫描 | + +--- + +## 三、DeepSeek 接入方案 + +### 3.1 接入方式 + +DeepSeek 支持 OpenAI 兼容的 Chat Completions API。服务端封装 `AIProvider` 接口,首版实现 `DeepSeekProvider`。 + +推荐配置: + +| 配置项 | 示例 | 说明 | +| --- | --- | --- | +| `AI_PROVIDER` | `deepseek` | 当前 AI Provider | +| `DEEPSEEK_BASE_URL` | `https://api.deepseek.com` | OpenAI 兼容接口 base URL | +| `DEEPSEEK_API_KEY` | `******` | DeepSeek API Key,只放密钥系统 | +| `DEEPSEEK_DEFAULT_MODEL` | `deepseek-flash` | 默认模型,适合 AI 美文、智能文档 | +| `DEEPSEEK_COMPLEX_MODEL` | `deepseek-v4-pro` | 复杂表格分析、会议总结可选 | +| `DEEPSEEK_TIMEOUT_MS` | `60000` | 单次请求超时 | +| `DEEPSEEK_MAX_RETRIES` | `2` | 供应商 429/5xx/网络错误重试次数 | +| `DEEPSEEK_TEMPERATURE` | `0.2` | 结构化输出建议较低温度 | +| `DEEPSEEK_MAX_OUTPUT_TOKENS` | `4096` | 输出上限 | + +不建议使用旧模型名 `deepseek-chat`、`deepseek-reasoner` 作为新接入默认值。模型名必须配置化,方便 DeepSeek 后续升级时只改配置。 + +### 3.2 Provider 接口 + +```go +type AIProvider interface { + Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) +} + +type GenerateRequest struct { + Feature string + TaskID string + TenantID string + UserID string + Model string + SystemPrompt string + UserPrompt string + ResponseSchema string + Temperature float64 + MaxTokens int + Timeout time.Duration +} + +type GenerateResponse struct { + ProviderRequestID string + Model string + Content string + PromptTokens int + CompletionTokens int + TotalTokens int + FinishReason string + LatencyMs int64 +} +``` + +业务层只依赖 `AIProvider`,不得直接依赖 DeepSeek HTTP Client。这样后续切换 Provider 或增加备用模型时不影响业务用例。 + +### 3.3 DeepSeek 请求格式 + +请求 DeepSeek 时统一调用: + +```http +POST /chat/completions HTTP/1.1 +Host: api.deepseek.com +Authorization: Bearer ${DEEPSEEK_API_KEY} +Content-Type: application/json +``` + +示例请求: + +```json +{ + "model": "deepseek-flash", + "messages": [ + { + "role": "system", + "content": "你是 IM 工作台的企业办公助手。请严格按 JSON 输出,不要输出额外解释。" + }, + { + "role": "user", + "content": "请将以下日报原文润色为专业、简洁的工作总结..." + } + ], + "temperature": 0.2, + "max_tokens": 800, + "stream": false +} +``` + +首版后端不使用流式输出,统一 `stream=false`。原因是前端当前交互以按钮生成、任务轮询、结果页展示为主;非流式更容易做幂等、审计、失败重试和结构化解析。后续如要做逐字生成,可在 Provider 层扩展流式接口。 + +### 3.4 输出结构化处理 + +DeepSeek 输出必须经过服务端解析和校验,不能直接原样入库给前端。 + +处理流程: + +1. Prompt 明确要求模型只输出 JSON。 +2. Provider 返回文本后,先截取 JSON 主体,去除 Markdown code fence。 +3. 按当前 feature 的 schema 反序列化。 +4. 校验必填字段、数组长度、字符串长度和枚举值。 +5. 校验失败时最多执行一次“修复 JSON”请求,仍失败则任务失败,reason=`AI_TASK_FAILED`。 +6. 校验通过后,生成标准化业务结果并入库。 + +示例:智能文档输出 Schema。 + +```json +{ + "title": "string", + "sections": [ + {"heading": "string", "content": "string"} + ], + "summary": "string" +} +``` + +示例:会议总结输出 Schema。 + +```json +{ + "conclusions": ["string"], + "decisions": ["string"], + "action_items": [ + { + "owner_name": "string", + "content": "string", + "due_text": "string" + } + ] +} +``` + +### 3.5 Prompt 模板管理 + +Prompt 不应散落在业务代码中,建议以数据库配置或内置模板文件管理。首版可内置在代码,并预留版本字段。 + +| 模板 | 用途 | 推荐模型 | 输出 | +| --- | --- | --- | --- | +| `smart_document_v1` | 智能文档生成 | `deepseek-flash` | JSON + markdown | +| `smart_table_v1` | 表格报告解读 | `deepseek-v4-pro` 或 `deepseek-flash` | JSON | +| `text_polish_v1` | AI 美文 | `deepseek-flash` | JSON | +| `meeting_summary_v1` | 会议 AI 总结 | `deepseek-v4-pro` 或 `deepseek-flash` | JSON | + +模板变量示例: + +| 变量 | 说明 | +| --- | --- | +| `{{language}}` | 输出语言 | +| `{{source_text}}` | 用户输入原文 | +| `{{document_type}}` | 文档类型 | +| `{{table_schema}}` | 表头、字段类型、样例数据 | +| `{{table_stats}}` | 服务端预计算统计 | +| `{{meeting_title}}` | 会议标题 | +| `{{meeting_files_text}}` | 会议附件抽取文本 | +| `{{participants}}` | 参会人展示名 | + +Prompt 版本必须写入 `wb_ai_task.prompt_version` 和 `wb_ai_record.prompt_version`,方便回溯同一条记录由哪个模板生成。 + +### 3.6 错误映射 + +| DeepSeek/网络错误 | 服务端 reason | 处理 | +| --- | --- | --- | +| 401/403 | `AI_PROVIDER_AUTH_FAILED` | 配置错误,告警,不重试 | +| 400 | `AI_PROVIDER_BAD_REQUEST` | Prompt 或参数错误,任务失败 | +| 429 | `AI_PROVIDER_RATE_LIMITED` | 按退避重试,仍失败返回 `RATE_LIMITED` | +| 5xx | `AI_PROVIDER_FAILED` | 重试,仍失败任务失败 | +| 网络超时 | `AI_TIMEOUT` | 重试,仍失败任务失败 | +| 输出为空 | `AI_TASK_FAILED` | 可重试一次 | +| JSON 解析失败 | `AI_TASK_FAILED` | 修复 JSON 一次,仍失败则失败 | + +Provider 原始错误只允许写入内部审计表的脱敏字段,不直接返回给前端。 + +### 3.7 超时、重试和熔断 + +- AI 美文同步接口总耗时目标小于 8 秒,超过直接返回 `AI_TIMEOUT`。 +- 智能文档单任务超时 60 秒。 +- 智能表格和会议总结单任务超时 120 秒。 +- 重试仅针对网络错误、429、5xx、输出为空;内容安全、参数错误、权限错误不重试。 +- 重试采用指数退避,例如 1 秒、3 秒。 +- 连续失败率超过阈值时开启 Provider 熔断,新的异步任务保持 `queued` 或快速失败,AI 美文返回“服务繁忙”。 + +### 3.8 安全与脱敏 + +发送 DeepSeek 前必须做最小必要原则: + +1. 智能文档只发送用户输入,不附带用户 Token、手机号、设备信息。 +2. 智能表格只发送表头、数据类型、聚合统计、异常点和必要样例,不发送完整大表。 +3. 会议总结只发送会议标题、附件抽取文本、参会人展示名;不发送内部权限字段。 +4. 日志只记录输入长度、输出长度、hash、模型、耗时、token 用量、错误 reason。 +5. API Key 只从密钥系统或环境变量读取,不入库、不打印、不下发前端。 + +--- + +## 四、数据库设计 + +### 4.1 数据库公共约定 + +- 数据库建议使用 MySQL 8.0,字符集 `utf8mb4`。 +- 所有 AI 表名使用 `wb_ai_` 前缀,主键可使用 `BIGINT UNSIGNED` 或字符串 ID,接口统一返回字符串。 +- 所有租户业务表必须包含 `tenant_id`、`tenant_code`、`user_id`。 +- `created_at`、`updated_at` 使用 `DATETIME(3)`;服务端写 UTC。 +- JSON 字段保存低频扩展内容;状态、类型、时间、用户、任务 ID 必须拆列建索引。 +- AI 原始输入和模型原始输出可按合规要求加密存储或只保存摘要,默认不保存完整供应商响应。 + +### 4.2 表清单 + +| 表名 | 领域 | 用途 | +| --- | --- | --- | +| `wb_ai_capability_config` | 配置 | 租户级 AI 能力开关、模型、限额 | +| `wb_ai_task` | 任务 | AI 异步任务主表 | +| `wb_ai_task_step` | 任务 | 表格等多步骤任务进度 | +| `wb_ai_record` | 记录 | 通用 AI 记录索引 | +| `wb_ai_document` | 智能文档 | 文档保存内容 | +| `wb_ai_table_analysis` | 智能表格 | 表格解读报告 | +| `wb_ai_meeting_summary` | 会议总结 | 会议 AI 总结内容 | +| `wb_ai_usage_log` | 用量 | AI 调用、token、耗时、结果 | +| `wb_ai_idempotency_record` | 通用 | 写接口幂等请求和结果摘要 | +| `wb_ai_prompt_template` | Prompt | Prompt 模板和版本,可后续管理后台维护 | + +### 4.3 `wb_ai_capability_config` — AI 能力配置 + +保存租户维度或默认维度的 AI 能力开关、模型、配额和限制。 + +关键字段: + +| 字段 | 说明 | +| --- | --- | +| `tenant_id` | 租户 ID,默认配置可使用 `0` | +| `feature` | `smart_document`、`smart_table`、`text_polish`、`meeting_summary` | +| `enabled` | 是否开启 | +| `model` | 默认模型,例如 `deepseek-flash` | +| `daily_limit` | 单用户每日次数 | +| `concurrency_limit` | 单用户并发任务数 | +| `max_input_chars` | 输入字符上限 | +| `max_file_size_mb` | 文件大小上限 | +| `config_json` | 扩展配置 | + +关键索引: + +- `uk_tenant_feature(tenant_id, feature)`。 +- 查询时先查租户配置,查不到再用默认配置。 + +### 4.4 `wb_ai_task` — AI 任务主表 + +智能文档、智能表格、会议总结都创建任务。AI 美文首版可同步处理,不强制落任务,但仍写 `wb_ai_usage_log`。 + +关键字段: + +| 字段 | 说明 | +| --- | --- | +| `task_id` | 对外任务 ID,例如 `aitask_xxx` | +| `tenant_id`、`tenant_code`、`user_id` | 数据隔离 | +| `feature` | AI 能力类型 | +| `status` | `queued`、`processing`、`succeeded`、`failed`、`canceled`、`expired` | +| `progress` | 0~100 | +| `source_type` | `text`、`file`、`meeting` | +| `source_ref_id` | 关联文件、会议或业务对象 ID | +| `input_hash` | 输入内容 hash,用于审计和幂等诊断 | +| `input_snapshot` | 脱敏后的输入快照,可按合规决定是否为空 | +| `prompt_version` | Prompt 模板版本 | +| `model` | 实际调用模型 | +| `result_ref_id` | 成功后的记录 ID | +| `error_reason`、`error_message` | 失败原因 | +| `retry_count` | 已重试次数 | +| `started_at`、`finished_at` | 执行时间 | + +关键索引: + +- `uk_task_id(task_id)`。 +- `idx_user_feature_time(tenant_id, user_id, feature, created_at)`。 +- `idx_status_time(status, created_at)`,用于 Worker 拉取和补偿。 + +### 4.5 `wb_ai_task_step` — AI 任务步骤 + +用于智能表格进度展示,也可复用到会议总结。 + +字段包括 `task_id`、`step`、`title`、`status`、`progress`、`started_at`、`finished_at`、`error_reason`。 + +智能表格固定步骤: + +1. `read_file`:读取表格数据。 +2. `detect_metrics`:识别核心指标。 +3. `analyze_trends`:分析趋势变化。 +4. `generate_report`:生成解读报告。 + +### 4.6 `wb_ai_record` — AI 记录索引 + +保存智能文档、智能表格等记录列表所需公共字段。 + +关键字段: + +| 字段 | 说明 | +| --- | --- | +| `record_id` | 对外记录 ID,例如 `airec_doc_xxx` | +| `record_type` | `smart_document`、`smart_table`、`meeting_summary` | +| `title` | 标题 | +| `summary` | 摘要 | +| `status` | `succeeded`、`deleted` 等 | +| `detail_table` | 详情表名或类型 | +| `detail_id` | 详情表 ID | +| `task_id` | 来源任务 | +| `created_at`、`updated_at`、`deleted_at` | 时间 | + +关键索引: + +- `uk_record_id(record_id)`。 +- `idx_user_type_time(tenant_id, user_id, record_type, created_at)`。 +- `idx_title_search(tenant_id, user_id, title)`,实际搜索可接全文索引或外部搜索。 + +### 4.7 `wb_ai_document` — 智能文档详情 + +保存用户确认保存后的智能文档。 + +关键字段: + +- `record_id` +- `document_type` +- `title` +- `content_markdown` +- `content_plain` +- `sections_json` +- `source_excerpt` +- `prompt_version` +- `model` + +智能文档生成成功但未保存时,只保留在 `wb_ai_task.result_json` 或短期缓存中;过期后不可保存。 + +### 4.8 `wb_ai_table_analysis` — 表格解读详情 + +保存表格报告解读结果。 + +关键字段: + +- `record_id` +- `file_id`、`file_name`、`file_type`、`file_size` +- `topic` +- `metrics_json` +- `preview_json` +- `alerts_json` +- `interpretation` +- `export_file_id` +- `prompt_version` +- `model` + +文件解析结果不建议完整入库。大表只保存字段统计、样例行和 AI 输出,完整源文件仍在 IM 文件服务中。 + +### 4.9 `wb_ai_meeting_summary` — 会议 AI 总结 + +保存会议维度的 AI 总结,一个会议默认只有一份当前有效总结。 + +关键字段: + +- `summary_id` +- `meeting_id` +- `task_id` +- `conclusions_json` +- `decisions_json` +- `action_items_json` +- `source_file_ids_json` +- `edited_by` +- `edited_at` +- `version` + +关键索引: + +- `uk_tenant_meeting(tenant_id, meeting_id)`。 +- `idx_user_time(tenant_id, user_id, updated_at)`。 + +编辑保存时使用 `version` 做乐观锁,避免多端同时编辑覆盖。 + +### 4.10 `wb_ai_usage_log` — AI 用量日志 + +每次调用 DeepSeek,无论成功失败都写用量日志。 + +字段包括: + +- `tenant_id`、`tenant_code`、`user_id` +- `feature` +- `task_id` +- `provider=deepseek` +- `model` +- `provider_request_id` +- `prompt_tokens`、`completion_tokens`、`total_tokens` +- `latency_ms` +- `status` +- `error_reason` +- `input_hash` +- `output_hash` +- `created_at` + +用量统计优先从该表聚合,日配额可用 Redis 加速、MySQL 兜底。 + +### 4.11 `wb_ai_idempotency_record` — 幂等记录 + +写接口统一记录幂等键、请求 hash 和响应摘要。 + +关键约束: + +- `uk_idempotency(tenant_id, user_id, idempotency_key)`。 +- 同一 key 请求 hash 相同,直接返回第一次响应。 +- 同一 key 请求 hash 不同,返回 `IDEMPOTENCY_CONFLICT`。 + +--- + +## 五、核心业务设计 + +### 5.1 AI 能力配置读取 + +`GET /v1/workbench/ai/capabilities` 流程: + +1. 解析 IM Token,得到租户和用户。 +2. 读取租户 AI 配置;租户没有配置时读取默认配置。 +3. 查询用户当天各 feature 已使用次数。 +4. 组装 `features[]`,返回开关、入口名、每日限制、剩余次数、输入限制、文件限制。 + +配置建议缓存 1 分钟。配置变化后可通过后台主动清缓存,首版可接受短延迟。 + +### 5.2 AI 美文同步流程 + +AI 美文用于短文本润色,流程: + +```text +请求进入 + -> 鉴权和租户解析 + -> 参数校验 source_text <= 300 + -> 能力开关、日配额、频控校验 + -> 内容安全预检查 + -> 构造 text_polish_v1 prompt + -> 调用 DeepSeek + -> 解析 JSON 输出 polished_text + -> 内容安全后检查 + -> 写 usage_log + -> 返回 polished_text +``` + +失败处理: + +- DeepSeek 超时:返回 `AI_TIMEOUT`。 +- 输出解析失败:重试一次;仍失败返回 `AI_TASK_FAILED`。 +- 内容安全失败:返回 `AI_CONTENT_REJECTED`。 +- 用量超限:返回 `AI_QUOTA_EXCEEDED`。 + +AI 美文不自动提交日报/月报,原业务提交接口保持不变。 + +### 5.3 智能文档流程 + +创建任务: + +1. 校验输入长度、能力开关、配额和幂等。 +2. 写 `wb_ai_task(status=queued, feature=smart_document)`。 +3. 投递 Kafka 消息或写入本地任务队列。 +4. 返回 `task_id` 给前端轮询。 + +Worker 执行: + +1. 将任务置为 `processing`。 +2. 构造 `smart_document_v1` prompt。 +3. 调用 DeepSeek。 +4. 解析 `title`、`sections`、`content_markdown`、`summary`。 +5. 结果写入 `wb_ai_task.result_json`,任务置为 `succeeded`。 +6. 用户点击保存后,写 `wb_ai_record` 和 `wb_ai_document`。 + +注意: + +- 生成成功不等于保存成功。 +- 未保存结果可设置 2 小时过期。 +- 重新生成创建新任务,不覆盖旧任务。 + +### 5.4 智能表格流程 + +智能表格必须先上传文件,再创建任务。 + +创建任务: + +1. 校验 `file_id` 属于当前租户和用户。 +2. 校验文件类型为 `xlsx/xls/csv`,大小不超过配置。 +3. 写 `wb_ai_task`、`wb_ai_task_step`,预生成 `record_id`。 +4. 投递任务,返回 `task_id`、`record_id`。 + +Worker 执行: + +```text +read_file + -> 从 IM 文件服务下载文件 + -> 解析 xlsx/xls/csv + -> 抽取表头、字段类型、样例行、总行数 +detect_metrics + -> 服务端先做基础统计:合计、均值、最大最小、环比、空值、异常点 +analyze_trends + -> 将表结构、统计摘要、异常点输入 DeepSeek +generate_report + -> 解析模型 JSON,生成指标、预览、异常提醒、核心解读 + -> 写 wb_ai_record、wb_ai_table_analysis +``` + +表格数据处理原则: + +- 不把完整大表直接发给 DeepSeek。 +- 优先由服务端计算确定性统计,再让 AI 解释。 +- 样例行数量限制,例如最多 20 行。 +- 敏感列可按列名规则脱敏,例如手机号、身份证、邮箱。 + +### 5.5 会议 AI 总结流程 + +会议 AI 总结必须复用会议笔记权限。 + +流程: + +1. 校验当前用户可查看该会议笔记。 +2. 获取会议标题、时间、组织者、参会人、附件列表。 +3. 校验 `file_ids` 均属于该会议且当前用户可读。 +4. 抽取附件文本;若附件为空,可基于会议描述和已有笔记文本生成,但必须有有效输入。 +5. 创建 `meeting_summary` AI 任务。 +6. Worker 调用 DeepSeek 生成结论、关键决策、后续安排。 +7. 写入或更新 `wb_ai_meeting_summary`。 + +编辑保存: + +- 校验会议笔记编辑权限。 +- 使用 `version` 乐观锁。 +- 保存用户编辑内容,记录 `edited_by`、`edited_at`。 + +### 5.6 导出报告流程 + +表格报告导出流程: + +1. 校验记录属于当前用户。 +2. 读取 `wb_ai_table_analysis`。 +3. 使用服务端模板生成 DOCX 或 PDF。 +4. 上传到 IM 文件服务,得到 `file_id`。 +5. 返回下载 URL 和过期时间。 + +导出不再调用 DeepSeek,避免同一报告重复生成内容不一致。 + +### 5.7 应用搜索接入 + +工作台应用列表和搜索本质不是 AI Provider 调用,但需要接入 AI 能力配置。 + +处理方式: + +- 应用注册表增加 AI 应用:`smart_document`、`smart_table`。 +- 返回应用列表前读取 capabilities。 +- 未开启的 AI 应用不返回,或返回 `enabled=false`,由产品决定。 +- 搜索只搜索当前用户可见应用。 + +--- + +## 六、关键技术方案 + +### 6.1 事务边界 + +需要事务的场景: + +- 创建 AI 任务和幂等记录。 +- 表格任务成功后写记录索引和详情。 +- 智能文档保存时写记录索引和详情。 +- 会议总结更新和版本递增。 +- 用量日志和日配额计数。 + +不应放入同一事务的场景: + +- 调用 DeepSeek。 +- 下载文件和解析大文件。 +- 导出文件上传。 +- 发送 WebSocket 或系统通知。 + +外部调用必须在事务外执行,事务只保护本地状态变更。 + +### 6.2 幂等与弱网 + +所有写接口都要求 `Idempotency-Key`: + +1. 请求进入后计算规范化请求体 hash。 +2. 查询 `wb_ai_idempotency_record`。 +3. 不存在则插入 `processing` 记录并继续。 +4. 已存在且 hash 相同,返回首次响应。 +5. 已存在且 hash 不同,返回 `IDEMPOTENCY_CONFLICT`。 +6. 业务成功后更新响应摘要。 + +异步任务的幂等响应应返回同一个 `task_id`,不能重复创建任务。 + +### 6.3 任务调度 + +首选 Kafka: + +| Topic | 用途 | +| --- | --- | +| `workbench.ai.task.created` | AI 任务创建 | +| `workbench.ai.task.retry` | AI 任务重试 | +| `workbench.ai.task.completed` | AI 任务完成事件,可选 | + +消息体: + +```json +{ + "task_id": "aitask_tbl_2Fh9aQx73K", + "tenant_id": "1", + "tenant_code": "system", + "user_id": "usrABAAnqj42BU", + "feature": "smart_table", + "created_at": "2026-09-25T08:30:15.123Z" +} +``` + +消费要求: + +- Consumer 收到消息后先按 `task_id` 查询 DB,只有 `queued` 可执行。 +- 任务置为 `processing` 使用条件更新,避免多 Consumer 重复执行。 +- 执行失败按错误类型决定是否重试。 +- 服务重启后通过补偿任务扫描 `queued` 和超时 `processing`。 + +如果首版 Kafka 未就绪,可用 MySQL 轮询表兜底,但接口设计保持不变。 + +### 6.4 并发与配额 + +配额维度: + +- 租户总开关。 +- 单用户每日次数。 +- 单用户并发任务数。 +- 单租户并发任务数。 +- Provider 全局并发数。 + +实现建议: + +- Redis 维护短期计数和并发锁。 +- MySQL `wb_ai_usage_log` 做最终用量统计。 +- Redis 不可用时,使用 MySQL 当天聚合兜底,宁可保守拒绝,不放宽限制。 + +### 6.5 表格解析 + +解析规则: + +- `xlsx/xls`:读取第一个非空 sheet;如果前端指定 `sheet_name`,必须校验存在。 +- `csv`:自动识别 UTF-8/GBK,分隔符支持逗号,必要时兼容制表符。 +- 空行、合并单元格、公式结果需要规范化。 +- 超过最大行数时只采样前 N 行,并计算全量统计时注意内存限制。 + +输出给 DeepSeek 的内容建议: + +```json +{ + "topic": "8月销售数据", + "columns": [ + {"name": "日期", "type": "date"}, + {"name": "销售额", "type": "number"}, + {"name": "订单量", "type": "number"} + ], + "row_count": 31, + "sample_rows": [ + ["8/1", "42300", "126"] + ], + "stats": { + "销售额": {"sum": 1286000, "max": 62000, "min": 32100}, + "订单量": {"sum": 3842} + }, + "outliers": [ + {"row": 15, "column": "订单量", "description": "较前一日下降 18%"} + ] +} +``` + +### 6.6 内容安全 + +内容安全分为前置检查和后置检查: + +- 前置检查:用户输入、表格标题、会议附件抽取文本。 +- 后置检查:DeepSeek 输出结果。 + +首版如没有独立内容安全服务,可先做基础敏感词、长度、HTML/script 清理和文件类型校验,同时预留 `safety` adapter。命中安全规则返回 `AI_CONTENT_REJECTED`。 + +### 6.7 缓存策略 + +| 缓存 | Key | TTL | 说明 | +| --- | --- | --- | --- | +| AI 能力配置 | `wb:ai:cap:{tenant_id}` | 60s | 能力开关和限制 | +| 用户日用量 | `wb:ai:quota:{tenant_id}:{user_id}:{date}` | 当天 | 快速判断剩余次数 | +| 任务进度 | `wb:ai:task:{task_id}` | 2h | 轮询加速 | +| Provider 熔断 | `wb:ai:provider:deepseek:circuit` | 30s | 熔断标记 | + +缓存不可作为唯一事实来源。任务最终状态以 MySQL 为准。 + +### 6.8 错误处理 + +对前端统一返回 workbench 错误包: + +```json +{ + "code": 30050, + "reason": "AI_TIMEOUT", + "message": "ai timeout", + "request_id": "01K8ZC3FZ6BTX9Z1SK8HV7QYWB", + "server_time": "2026-09-25T08:30:15.123Z" +} +``` + +错误分层: + +- 参数和权限错误:接口同步返回。 +- 异步任务执行错误:任务置为 `failed`,轮询接口返回 `error`。 +- Provider 配置错误:接口返回失败并触发告警。 +- Kafka 不可用:任务保持 `queued`,补偿扫描重投。 + +### 6.9 可观测性 + +日志字段: + +- `request_id` +- `tenant_code` +- `user_id_hash` +- `feature` +- `task_id` +- `provider` +- `model` +- `status` +- `latency_ms` +- `prompt_tokens` +- `completion_tokens` +- `error_reason` + +指标: + +- AI 请求数、成功数、失败数。 +- DeepSeek 调用耗时 P50/P90/P99。 +- 任务排队时长、执行时长。 +- 任务失败率、重试次数。 +- 每租户 token 用量。 +- Provider 429/5xx/超时次数。 + +告警: + +- DeepSeek 连续 5 分钟失败率超过阈值。 +- 任务积压超过阈值。 +- Kafka 消费延迟超过阈值。 +- API Key 鉴权失败。 +- 单租户用量异常增长。 + +--- + +## 七、C 端与管理后台需求 + +### 7.1 C 端职责边界 + +C 端负责: + +- 输入长度和文件类型的第一层校验。 +- 调用任务创建接口并轮询。 +- 展示 AI 结果、失败、重试、取消和空态。 +- 在日报/月报中使用 AI 美文结果回填字段。 +- 展示“由 AI 生成,请核对后使用”提示。 + +C 端不负责: + +- 判断最终权限。 +- 控制真实用量。 +- 保存 DeepSeek Key。 +- 拼接最终 prompt。 +- 解析供应商原始响应。 + +### 7.2 管理后台能力 + +建议后续在 manager 或 workbench admin 增加 AI 配置: + +| 能力 | 说明 | +| --- | --- | +| 租户 AI 总开关 | 控制某租户是否开放 AI | +| 功能开关 | 分别控制智能文档、智能表格、AI 美文、会议总结 | +| 模型配置 | 每个功能选择默认模型 | +| 每日限额 | 单用户每日次数、租户每日总次数 | +| 文件限制 | 表格文件大小、行数、类型 | +| Prompt 版本 | 查看当前模板版本,灰度新模板 | +| 用量统计 | 按租户、用户、功能统计 token 和次数 | +| 失败日志 | 查看脱敏后的失败原因和任务 ID | + +### 7.3 运维配置 + +Kubernetes/Gitea Secrets 中需要配置: + +```text +AI_PROVIDER=deepseek +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_API_KEY= +DEEPSEEK_DEFAULT_MODEL=deepseek-flash +DEEPSEEK_COMPLEX_MODEL=deepseek-v4-pro +DEEPSEEK_TIMEOUT_MS=60000 +DEEPSEEK_MAX_RETRIES=2 +DEEPSEEK_TEMPERATURE=0.2 +``` + +注意: + +- `DEEPSEEK_API_KEY` 只能放 Secret,不放 ConfigMap,不提交 Git。 +- 发版前确认 Pod 环境变量或配置文件最终生效。 +- 模型名必须和 DeepSeek 当前可用模型一致。 +- 不同环境可使用不同 Key 和限额。 + +--- + +## 八、开发阶段与依赖 + +### 8.1 开发阶段 + +#### 阶段一:基础框架和 AI 美文 + +目标: + +- 建表:能力配置、用量日志、幂等。 +- 实现 DeepSeek Provider。 +- 实现 AI 美文接口。 +- 接入日报/月报回填流程。 + +验收: + +- 能通过 DeepSeek 返回润色文案。 +- 超时、Key 错误、用量超限有明确错误。 +- 日报/月报原业务提交不受影响。 + +#### 阶段二:智能文档 + +目标: + +- 实现 AI 任务表、任务轮询、智能文档生成、保存、记录列表。 +- 接入 Kafka 或 MySQL 任务队列。 + +验收: + +- 能生成文档、重新生成、保存、查询记录。 +- 弱网重复提交不创建重复任务。 + +#### 阶段三:智能表格 + +目标: + +- 文件归属校验、Excel/CSV 解析、统计摘要、DeepSeek 报告生成、记录详情。 +- 实现导出 DOCX/PDF。 + +验收: + +- xlsx/xls/csv 都能分析。 +- 大文件不会把完整内容发给 DeepSeek。 +- 任务进度和失败状态可见。 + +#### 阶段四:会议 AI 总结 + +目标: + +- 接入会议笔记权限和附件。 +- 生成会议结论、关键决策、后续安排。 +- 支持编辑保存。 + +验收: + +- 无权限不能生成或查看。 +- 会议附件不可用时有明确失败原因。 +- 编辑保存不会被任务结果覆盖。 + +### 8.2 开发依赖 + +| 依赖 | 负责人 | 说明 | +| --- | --- | --- | +| DeepSeek API Key | 运维/后端 | 测试、生产分别配置 | +| Kafka | 后端/运维 | AI 异步任务队列 | +| Redis | 后端/运维 | 配额、进度缓存、并发锁 | +| 文件服务 | IM 后端 | 表格源文件下载、导出文件上传 | +| 会议笔记接口 | workbench 后端 | 会议权限、附件列表 | +| 前端页面 | 前端 | AI 入口、轮询、结果、记录 | + +--- + +## 九、发布与回滚 + +### 9.1 发布顺序 + +1. 发布数据库 migration。 +2. 配置 DeepSeek Secret、AI Provider、模型和限额。 +3. 发布后端代码,默认租户 AI 开关关闭。 +4. 验证 Provider 健康检查和 AI 美文测试接口。 +5. 灰度开启测试租户 AI 美文。 +6. 依次开启智能文档、智能表格、会议总结。 +7. 前端打开入口。 + +### 9.2 发布检查 + +- Pod 中 `DEEPSEEK_API_KEY` 已注入,但日志不打印明文。 +- `DEEPSEEK_BASE_URL` 为 `https://api.deepseek.com`。 +- 默认模型可调用。 +- Kafka consumer 正常消费。 +- Redis 可写入配额 key。 +- MySQL 表和索引已创建。 +- 文件服务可下载测试表格。 +- 任务失败告警已配置。 + +### 9.3 回滚策略 + +| 问题 | 回滚方式 | +| --- | --- | +| DeepSeek 大面积失败 | 关闭租户 AI 开关或 Provider 熔断 | +| AI 美文影响日报/月报 | 隐藏 AI 美文入口,原提交接口不回滚 | +| 智能文档失败率高 | 关闭 `smart_document` 功能 | +| 智能表格解析异常 | 关闭 `smart_table` 功能,保留历史记录只读 | +| 会议总结权限问题 | 关闭 `meeting_summary` 功能,保留会议笔记原能力 | +| 新表结构问题 | 代码回滚到不访问新表版本;migration 回滚需单独评估 | + +回滚时不删除已生成记录。前端入口关闭后,历史记录可按产品要求保留只读或隐藏。 + +--- + +## 附录 A:MySQL 8.0 建表草案 + +```sql +CREATE TABLE wb_ai_capability_config ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + tenant_code VARCHAR(64) NOT NULL DEFAULT '', + feature VARCHAR(64) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + model VARCHAR(64) NOT NULL DEFAULT 'deepseek-flash', + daily_limit INT NOT NULL DEFAULT 0, + concurrency_limit INT NOT NULL DEFAULT 1, + max_input_chars INT NOT NULL DEFAULT 0, + max_file_size_mb INT NOT NULL DEFAULT 0, + config_json JSON NULL, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_tenant_feature (tenant_id, feature) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE wb_ai_task ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id VARCHAR(64) NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + tenant_code VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + feature VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + progress INT NOT NULL DEFAULT 0, + source_type VARCHAR(32) NOT NULL DEFAULT '', + source_ref_id VARCHAR(128) NOT NULL DEFAULT '', + input_hash VARCHAR(128) NOT NULL DEFAULT '', + input_snapshot JSON NULL, + prompt_version VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(64) NOT NULL DEFAULT '', + result_ref_id VARCHAR(64) NOT NULL DEFAULT '', + result_json JSON NULL, + error_reason VARCHAR(64) NOT NULL DEFAULT '', + error_message VARCHAR(255) NOT NULL DEFAULT '', + retry_count INT NOT NULL DEFAULT 0, + started_at DATETIME(3) NULL, + finished_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_task_id (task_id), + KEY idx_user_feature_time (tenant_id, user_id, feature, created_at), + KEY idx_status_time (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE wb_ai_task_step ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id VARCHAR(64) NOT NULL, + step VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + progress INT NOT NULL DEFAULT 0, + error_reason VARCHAR(64) NOT NULL DEFAULT '', + started_at DATETIME(3) NULL, + finished_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_task_step (task_id, step) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE wb_ai_record ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + record_id VARCHAR(64) NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + tenant_code VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + record_type VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + summary VARCHAR(512) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL, + detail_id VARCHAR(64) NOT NULL DEFAULT '', + task_id VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_record_id (record_id), + KEY idx_user_type_time (tenant_id, user_id, record_type, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE wb_ai_document ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + record_id VARCHAR(64) NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id VARCHAR(64) NOT NULL, + document_type VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + content_markdown MEDIUMTEXT NOT NULL, + content_plain MEDIUMTEXT NULL, + sections_json JSON NULL, + source_excerpt VARCHAR(1024) NOT NULL DEFAULT '', + prompt_version VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_record_id (record_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE wb_ai_table_analysis ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + record_id VARCHAR(64) NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id VARCHAR(64) NOT NULL, + file_id VARCHAR(128) NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_type VARCHAR(16) NOT NULL, + file_size BIGINT UNSIGNED NOT NULL DEFAULT 0, + topic VARCHAR(128) NOT NULL, + metrics_json JSON NULL, + preview_json JSON NULL, + alerts_json JSON NULL, + interpretation MEDIUMTEXT NULL, + export_file_id VARCHAR(128) NOT NULL DEFAULT '', + prompt_version VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_record_id (record_id), + KEY idx_file_id (tenant_id, file_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE wb_ai_meeting_summary ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + summary_id VARCHAR(64) NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + tenant_code VARCHAR(64) NOT NULL, + meeting_id VARCHAR(128) NOT NULL, + user_id VARCHAR(64) NOT NULL, + task_id VARCHAR(64) NOT NULL DEFAULT '', + conclusions_json JSON NULL, + decisions_json JSON NULL, + action_items_json JSON NULL, + source_file_ids_json JSON NULL, + prompt_version VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(64) NOT NULL DEFAULT '', + edited_by VARCHAR(64) NOT NULL DEFAULT '', + edited_at DATETIME(3) NULL, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_summary_id (summary_id), + UNIQUE KEY uk_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE wb_ai_usage_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + tenant_code VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + feature VARCHAR(64) NOT NULL, + task_id VARCHAR(64) NOT NULL DEFAULT '', + provider VARCHAR(32) NOT NULL, + model VARCHAR(64) NOT NULL, + provider_request_id VARCHAR(128) NOT NULL DEFAULT '', + prompt_tokens INT NOT NULL DEFAULT 0, + completion_tokens INT NOT NULL DEFAULT 0, + total_tokens INT NOT NULL DEFAULT 0, + latency_ms INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL, + error_reason VARCHAR(64) NOT NULL DEFAULT '', + input_hash VARCHAR(128) NOT NULL DEFAULT '', + output_hash VARCHAR(128) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + KEY idx_user_feature_day (tenant_id, user_id, feature, created_at), + KEY idx_provider_time (provider, model, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE wb_ai_idempotency_record ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id VARCHAR(64) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash VARCHAR(128) NOT NULL, + response_code INT NOT NULL DEFAULT 0, + response_body JSON NULL, + status VARCHAR(32) NOT NULL, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_idempotency (tenant_id, user_id, idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +## 附录 B:DeepSeek 调用伪代码 + +```go +func (p *DeepSeekProvider) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) { + body := map[string]any{ + "model": req.Model, + "messages": []map[string]string{ + {"role": "system", "content": req.SystemPrompt}, + {"role": "user", "content": req.UserPrompt}, + }, + "temperature": req.Temperature, + "max_tokens": req.MaxTokens, + "stream": false, + } + + httpReq := NewJSONRequest(ctx, "POST", p.baseURL+"/chat/completions", body) + httpReq.Header.Set("Authorization", "Bearer "+p.apiKey) + + resp, err := p.httpClient.Do(httpReq) + if err != nil { + return GenerateResponse{}, mapNetworkError(err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return GenerateResponse{}, mapDeepSeekStatus(resp.StatusCode, resp.Body) + } + + parsed := parseChatCompletion(resp.Body) + return GenerateResponse{ + ProviderRequestID: parsed.ID, + Model: parsed.Model, + Content: parsed.Choices[0].Message.Content, + PromptTokens: parsed.Usage.PromptTokens, + CompletionTokens: parsed.Usage.CompletionTokens, + TotalTokens: parsed.Usage.TotalTokens, + FinishReason: parsed.Choices[0].FinishReason, + LatencyMs: elapsed.Milliseconds(), + }, nil +} +``` + +## 附录 C:已确认产品与技术决策 + +| 决策 | 说明 | +| --- | --- | +| AI Provider | 首版接入 DeepSeek | +| 接口形态 | DeepSeek 走 OpenAI 兼容 Chat Completions | +| 默认模型 | `deepseek-flash` | +| 复杂任务模型 | 可配置为 `deepseek-v4-pro` | +| 流式输出 | 首版不做流式,统一非流式和任务轮询 | +| 表格处理 | 服务端先解析和统计,不把完整大表直接发给模型 | +| 日报/月报 | AI 只润色文案,不改变原提交接口 | +| 会议总结 | 必须复用会议笔记权限 | +| 日志 | 不打印完整输入、输出、Token、API Key | diff --git a/docs/IM工作台AI接入需求文档.md b/docs/IM工作台AI接入需求文档.md new file mode 100644 index 0000000..48d6ffc --- /dev/null +++ b/docs/IM工作台AI接入需求文档.md @@ -0,0 +1,1302 @@ +# IM 工作台 AI 接入需求文档 + +> 本文档根据 `docs/ai/` 目录下的工作台 AI 原型图整理,并参照 `docs/IM-产品原型需求文档.md` 的编写方式补充产品流程、前后端接口、服务端任务、数据存储和异常处理要求。原型图路径均相对于本文档所在 `docs/` 目录。 + +## 产品总体定位 + +工作台 AI 是 IM 企业工作台中的智能办公能力集合,目标是在不打断原有工作台、日报、月报、会议笔记等业务流程的前提下,为用户提供文本优化、文档生成、表格解读、会议总结和办公应用检索能力。 + +首版能力聚焦 5 类场景: + +1. 工作台入口与应用搜索:在工作台首页展示 AI 能力入口,并支持搜索工作台应用。 +2. 智能文档:根据用户输入的核心内容生成结构化文档,并支持保存和查看生成记录。 +3. 智能表格:上传 Excel/CSV 报表,由 AI 生成指标、数据预览、异常提醒和核心解读,并支持导出报告。 +4. AI 美文:对日报、月报等用户输入内容进行语言润色优化,支持重新生成和使用文案。 +5. 会议笔记 AI 总结:基于会议资料、会议笔记或上传文件生成会议结论、关键决策和后续安排。 + +系统设计上,AI 生成类功能必须采用异步任务模型,保留任务状态、输入内容、文件来源、生成结果、错误原因和用户操作记录;同时必须保证租户隔离、用户权限校验、敏感信息保护和生成结果可追溯。 + +## 0. 通用约定 + +### 0.1 入口与鉴权 + +工作台 AI 接口统一挂载在工作台服务下: + +```text +/v1/workbench/ai/* +``` + +公共请求头沿用工作台接口约定: + +```http +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: +X-Request-ID: +Idempotency-Key: # 写请求必传 +``` + +服务端从 IM 登录态解析当前用户和租户,不允许前端在请求体中伪造 `tenant_id`、`user_id`、`internal_user_id`。 + +### 0.2 租户隔离 + +AI 任务、记录、上传文件和导出文件必须按租户隔离。 + +要求: + +1. 所有 AI 数据表必须包含 `tenant_id`。 +2. 查询记录时只能返回当前租户、当前用户有权限查看的数据。 +3. 会议总结类任务必须校验当前用户是否可查看该会议笔记。 +4. 日报、月报 AI 美文只允许操作当前用户本人正在编辑的草稿内容。 +5. 后台管理或运营查询必须走独立内部接口,并记录审计日志。 + +### 0.3 AI 内容安全 + +AI 生成内容需要明确展示“由 AI 生成,请核对后使用”一类提示。前端不得默认替用户提交 AI 结果,必须由用户主动点击“使用文案”“保存文档”“提交”“导出”等操作。 + +服务端要求: + +1. 保存原始输入和 AI 输出,便于用户追溯和问题排查。 +2. 不把用户输入、AI 输出、文件内容直接写入普通日志。 +3. 调用第三方 AI 服务时,按租户和用户记录调用量、token 用量、耗时、状态和错误码。 +4. AI 输出失败、超时、内容违规时返回稳定错误码,前端展示可重试提示。 +5. 对上传文件做类型、大小、行数和解析安全校验,避免公式注入、超大文件和恶意内容影响服务。 + +### 0.4 任务状态 + +AI 生成类任务统一使用如下状态: + +| 状态 | 含义 | 前端表现 | +| --- | --- | --- | +| `pending` | 已创建,等待执行 | 显示排队中 | +| `processing` | 正在处理 | 显示进度页或 loading | +| `succeeded` | 生成成功 | 展示结果,可保存/导出/使用 | +| `failed` | 生成失败 | 展示失败原因和重试入口 | +| `canceled` | 用户取消 | 返回上一页或记录为已取消 | +| `expired` | 临时结果过期 | 提示重新生成 | + +表格解读可额外返回分阶段进度: + +| 阶段 | 含义 | +| --- | --- | +| `read_table` | 读取表格数据 | +| `extract_metrics` | 识别核心指标 | +| `analyze_trends` | 分析趋势变化 | +| `generate_report` | 生成解读报告 | + +### 0.5 统一响应 + +成功响应: + +```json +{ + "code": 0, + "data": {}, + "request_id": "uuid", + "server_time": "2026-09-25T10:00:00Z" +} +``` + +失败响应: + +```json +{ + "code": 30000, + "reason": "MALFORMED_REQUEST", + "message": "malformed request", + "details": { + "field": "title" + }, + "request_id": "uuid", + "server_time": "2026-09-25T10:00:00Z" +} +``` + +## 1. 工作台首页与 AI 入口 + +### 1.1 工作台.png + +工作台 + +功能说明: + +工作台首页展示顶部搜索框、AI 欢迎区、常用软件和全部应用。AI 欢迎区使用机器人形象和提示文案“你好,开始高效办公吧 / 整理文档、解读报表,让工作更轻松”,用于引导用户进入智能办公能力。 + +常用软件首版包含: + +1. 打卡 +2. 日报 +3. 智能文档 +4. 智能表格 + +全部应用首版包含: + +1. 部门框架 +2. 举报反馈 +3. 会议笔记 +4. 会议通知 +5. 月报 + +业务规则: + +1. 工作台首页应用列表由服务端返回,前端按服务端排序展示。 +2. `智能文档` 和 `智能表格` 需要支持开关控制,租户未启用 AI 时不展示或置灰。 +3. 首页未读角标沿用工作台既有能力,例如会议通知、工作台入口底部角标。 +4. AI 欢迎区仅作为引导,不直接触发生成任务。 +5. 页面加载失败时,保留本地缓存应用列表,并提示稍后重试。 + +建议接口: + +```http +GET /v1/workbench/ai/capabilities +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "enabled": true, + "features": { + "smart_document": true, + "smart_table": true, + "text_polish": true, + "meeting_summary": true + }, + "limits": { + "document_input_max_chars": 1000, + "text_polish_max_chars": 300, + "table_file_max_size": 10485760 + } + } +} +``` + +## 2. 工作台应用搜索 + +### 2.1 工作台-搜索页面.png + +工作台-搜索页面 + +功能说明: + +用户点击工作台首页搜索框后进入搜索页。页面顶部展示返回按钮、搜索输入框和“取消”按钮;默认展示常用应用宫格,包括打卡、日报、智能文档、智能表格、部门框架、举报反馈、会议笔记、会议通知。 + +业务规则: + +1. 搜索框 placeholder 为“搜索应用”。 +2. 未输入关键词时展示常用应用。 +3. 点击“取消”或左上角返回按钮回到工作台首页。 +4. 点击任一应用进入对应功能页。 +5. 搜索结果应只返回当前租户启用、当前用户有权限访问的应用。 + +### 2.2 工作台-搜索页面-明确展示.png + +工作台-搜索页面-明确展示 + +功能说明: + +用户输入关键词后展示“搜索结果”卡片。结果项展示应用图标、应用名称和右侧箭头,点击进入对应应用。 + +业务规则: + +1. 支持按应用名称、别名、拼音首字母进行搜索。 +2. 搜索关键词前后空格需要 trim。 +3. 支持模糊匹配,例如搜索“组织框架”命中“部门框架”或“组织框架”,具体别名由服务端配置。 +4. 搜索结果按常用度、名称匹配度、应用排序综合排序。 + +建议接口: + +```http +GET /v1/workbench/apps/search?q=组织框架 +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "items": [ + { + "app_id": "organization", + "name": "组织框架", + "icon": "organization", + "route": "/workbench/organization", + "enabled": true + } + ] + } +} +``` + +### 2.3 工作台-搜索页面-无结果.png + +工作台-搜索页面-无结果 + +功能说明: + +无匹配应用时展示空状态插画和“暂无匹配结果”文案。 + +业务规则: + +1. 搜索接口返回空数组时展示该状态。 +2. 网络异常不展示“暂无匹配结果”,应展示网络错误或重试提示。 +3. 清空关键词后恢复常用应用列表。 + +## 3. 智能文档 + +智能文档用于将用户输入的核心内容生成结构化办公文档,适合会议纪要、沟通纪要、任务安排、项目说明等轻量文档场景。 + +### 3.1 智能文档(2).png + +智能文档输入空状态 + +功能说明: + +智能文档首页顶部标题为“智能文档”,右上角展示“文档记录”入口。主体区域为“核心内容”输入框,placeholder 为“请输入”,右下角展示字数计数 `0/1000`,底部固定“生成文档”按钮。 + +业务规则: + +1. 核心内容必填,最大 1000 字。 +2. 未输入内容时,“生成文档”按钮可置灰或点击后提示“请输入核心内容”。 +3. 输入超过 1000 字时禁止继续输入或提交时返回字段错误。 +4. 点击“文档记录”进入当前用户的文档生成记录。 +5. 返回按钮退出智能文档页,若有未提交内容,应按产品策略提示是否放弃草稿。 + +### 3.2 智能文档(1).png + +智能文档输入内容 + +功能说明: + +用户输入核心内容后,可点击“生成文档”。输入内容示例为新项目沟通、人员安排、功能范围和待确认事项。 + +生成规则: + +1. AI 应从原始内容中识别主题、沟通事项、功能点、责任人、时间节点和待办事项。 +2. 生成内容应使用清晰标题和分段结构。 +3. 如果输入中包含人员和时间,应尽量保留并结构化。 +4. 不应凭空生成用户未提及的关键事实;可用“待确认”表达不确定项。 + +建议接口: + +```http +POST /v1/workbench/ai/documents/generate +Idempotency-Key: +``` + +请求示例: + +```json +{ + "source_text": "今天讨论新项目,确定新增群聊功能和朋友圈两个入口。小林负责设计,下周三交初稿;小陈负责开发,下周五给排期。首版支持手动输入、总结要点和生成文档。导出格式还需要确认。", + "title_hint": "新项目沟通纪要" +} +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_7abc", + "status": "processing" + } +} +``` + +### 3.3 智能文档.png + +智能文档生成结果 + +功能说明: + +文档生成完成后,页面展示 AI 生成文档。顶部保留“智能文档”标题和“编辑”入口;内容顶部显示“由 AI 生成”标签,正文包含文档标题“新项目沟通纪要”和分段内容,例如沟通事项、首版功能、任务安排、待办事项。底部展示“由 AI 整理,请核对后使用”提示,并提供“重新生成”和“保存文档”按钮。 + +业务规则: + +1. AI 结果必须标识来源,不允许伪装成用户手写内容。 +2. “重新生成”使用同一原始输入创建新一版结果,需要保留版本号。 +3. “保存文档”将当前结果保存为文档记录,状态变为 `succeeded`。 +4. “编辑”允许用户对 AI 结果进行人工修改,修改后保存的是用户确认后的最终内容。 +5. 保存成功后可进入详情或返回工作台,具体交互由客户端确定。 +6. 如果 AI 生成失败,展示失败状态和重试按钮。 + +生成结果查询接口: + +```http +GET /v1/workbench/ai/tasks/{task_id} +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_7abc", + "task_type": "smart_document", + "status": "succeeded", + "result": { + "title": "新项目沟通纪要", + "content_markdown": "一、沟通事项\n\n新增群聊功能和朋友圈两个入口。\n\n二、首版功能\n\n1. 手动输入重点内容。\n2. 智能总结要点。\n3. 生成结构化文档。", + "ai_disclaimer": "由 AI 整理,请核对后使用" + } + } +} +``` + +保存接口: + +```http +POST /v1/workbench/ai/documents +Idempotency-Key: +``` + +请求示例: + +```json +{ + "task_id": "aitask_7abc", + "title": "新项目沟通纪要", + "content_markdown": "..." +} +``` + +### 3.4 总结记录(1).png + +文档记录 + +功能说明: + +文档记录页展示当前用户保存过的智能文档记录。顶部为关闭按钮、标题“文档记录”和搜索框。列表按日期分组,例如“今天”;每条记录展示 Word 图标、标题、文件类型、时间、状态“已完成”和右侧箭头。 + +业务规则: + +1. 仅展示当前用户本人创建或有权限查看的文档记录。 +2. 支持按标题搜索。 +3. 记录按创建时间倒序排列。 +4. 状态可展示 `已完成`、`生成中`、`失败`。 +5. 点击记录进入文档详情。 + +建议接口: + +```http +GET /v1/workbench/ai/records?type=smart_document&query=&cursor=&limit=20 +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "items": [ + { + "record_id": "airec_doc_1", + "type": "smart_document", + "title": "8月销售数据分析", + "file_type": "word", + "status": "succeeded", + "display_status": "已完成", + "created_at": "2026-09-25T02:30:00Z" + } + ], + "next_cursor": "", + "has_more": false + } +} +``` + +## 4. 智能表格与报告解读 + +智能表格用于上传 Excel 或 CSV 报表,AI 自动读取表格数据、识别核心指标、分析趋势变化,并生成解读报告。 + +### 4.1 智能表格.png + +智能表格 + +功能说明: + +页面标题区包含关闭按钮和“解读记录”入口。主体顶部为报表主题输入区域,placeholder 为“输入报表主题”。下方说明“上传将获得解读以下内容”,并列出 AI 解读能力: + +1. 核心指标:快速提取重要业务数据。 +2. 异常提醒:定位需要重点关注的数据。 +3. 数据预览:分析环比、同比及变化趋势。 +4. 核心解读:用简单语言说明数据结论。 + +底部固定按钮“选择文件”。 + +业务规则: + +1. 报表主题必填,建议最大 50 字。 +2. 用户必须选择文件后才能开始分析。 +3. 支持 `xls`、`xlsx`、`csv`。 +4. 首版每次只支持上传一个文件。 +5. 已选择文件后可删除并重新选择。 +6. 点击“解读记录”进入表格解读记录页。 + +### 4.2 选择文件.png + +选择文件 + +功能说明: + +点击“选择文件”后从底部弹出选择报表文件弹窗。可选择: + +1. Excel 工作簿:支持 `XLS`、`XLSX` +2. CSV 数据文件:支持 `CSV` + +底部提供“取消”按钮。 + +业务规则: + +1. 文件类型必须由前后端共同校验,不能只按文件后缀判断。 +2. 文件大小建议首版限制为 10 MB,可由服务端配置。 +3. 表格行数、列数超过限制时返回明确错误。 +4. 文件上传失败时保留当前页面,提示重新上传。 +5. 文件中如存在多个 sheet,服务端默认读取第一个非空 sheet;后续可扩展 sheet 选择。 + +文件上传建议沿用 IM 文件接口: + +```http +POST /v0/file/u/ +X-MeetingIM-Auth: token +X-MeetingIM-APIKey: +multipart/form-data +``` + +表单字段建议: + +```text +id= +purpose=ai_table_source +file= +``` + +文件服务返回 `file_id` 后,再提交给工作台 AI 分析接口。 + +### 4.3 智能表格-文件上传成功.png + +智能表格-文件上传成功 xlsx + +### 4.4 智能表格-文件上传成功(1).png + +智能表格-文件上传成功 xls + +### 4.5 智能表格-文件上传成功(2).png + +智能表格-文件上传成功 csv + +功能说明: + +文件上传成功后,页面展示报表主题,例如“8月销售数据分析”;展示已选择报表卡片,包括文件类型图标、文件名、文件大小、上传成功状态和删除按钮。底部按钮变为“开始分析”。 + +业务规则: + +1. 上传成功后展示真实文件名和大小。 +2. 删除按钮用于移除当前文件,回到未选择文件状态。 +3. 点击“开始分析”创建 AI 表格解读任务。 +4. 同一个 `Idempotency-Key` 重复提交应返回同一个任务,避免重复扣费和重复生成。 + +创建分析任务接口: + +```http +POST /v1/workbench/ai/table-analyses +Idempotency-Key: +``` + +请求示例: + +```json +{ + "title": "8月销售数据分析", + "file_id": "DDd-DJUhRUA", + "file_name": "8月销售数据分析.xlsx", + "file_type": "xlsx" +} +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_table_1", + "status": "processing", + "progress": 0, + "current_step": "read_table" + } +} +``` + +### 4.6 会议总结-识别中.png + +智能解读报告处理中 + +功能说明: + +表格分析过程中展示“智能解读报告”进度页。页面包含“正在智能解读”标题、说明“分析报表数据,请稍候...”、进度条、百分比、文件信息卡片和步骤时间轴。 + +步骤包括: + +1. 读取表格数据:已完成 +2. 识别核心指标:已完成 +3. 分析趋势变化:进行中 +4. 生成解读报告:等待中 + +底部提供“取消分析”按钮。 + +业务规则: + +1. 前端可轮询任务状态,也可后续升级为 WebSocket/SSE 推送。 +2. 进度值由服务端返回,不允许前端假进度超过服务端状态。 +3. 用户点击“取消分析”后,服务端将任务标记为 `canceled`;如果第三方 AI 调用不可中断,也应停止后续结果写入。 +4. 任务处理中退出页面,再进入记录页应能看到“生成中”状态。 +5. 任务超时后进入 `failed`,错误原因展示为“分析超时,请稍后重试”。 + +查询任务接口: + +```http +GET /v1/workbench/ai/tasks/{task_id} +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_table_1", + "task_type": "smart_table", + "status": "processing", + "progress": 68, + "current_step": "analyze_trends", + "steps": [ + {"key": "read_table", "name": "读取表格数据", "status": "succeeded"}, + {"key": "extract_metrics", "name": "识别核心指标", "status": "succeeded"}, + {"key": "analyze_trends", "name": "分析趋势变化", "status": "processing"}, + {"key": "generate_report", "name": "生成解读报告", "status": "pending"} + ], + "source_file": { + "file_id": "DDd-DJUhRUA", + "file_name": "8月销售数据.xlsx", + "file_size": 3355443, + "file_type": "xlsx" + } + } +} +``` + +取消接口: + +```http +POST /v1/workbench/ai/tasks/{task_id}/cancel +Idempotency-Key: +``` + +### 4.7 报告解读.png + +报告解读 + +功能说明: + +表格分析完成后进入“报告解读”页面。页面展示: + +1. 报告标题:例如“8月销售数据”。 +2. 元信息:今天 10:30、Excel 报表。 +3. 核心指标卡片:销售额、订单量、退款率,包含指标值和涨跌幅。 +4. 数据预览表:月份、销售额、订单量、退款率等核心列。 +5. 异常提醒:例如“退款率连续两周上升,华南区域增幅明显”。 +6. 核心解读:自然语言总结业务结论和建议。 +7. 底部按钮:导出解读报告。 + +业务规则: + +1. 指标卡最多展示 3 个核心指标,超过时由 AI 选取最重要指标。 +2. 数据预览最多展示 10 行、6 列,完整文件仍通过原文件查看或导出查看。 +3. 异常提醒为空时隐藏该模块或展示“暂无明显异常”。 +4. 核心解读必须以用户能理解的业务语言输出,避免只重复表格数字。 +5. 导出报告应生成 Word 或 PDF,首版建议 Word。 +6. 导出文件应记录到文件系统,支持下载和在解读记录中查看。 + +结果结构示例: + +```json +{ + "title": "8月销售数据", + "source_type": "excel", + "metrics": [ + {"name": "销售额", "value": "286万", "change": "+18%", "trend": "up"}, + {"name": "订单量", "value": "12,680", "change": "+12%", "trend": "up"}, + {"name": "退款率", "value": "3.6%", "change": "+0.8%", "trend": "up"} + ], + "preview": { + "columns": ["月份", "销售额", "订单量", "退款率"], + "rows": [ + ["3月", "98万", "8,620", "+5.8%"], + ["4月", "198万", "9,450", "+8.6%"] + ] + }, + "warnings": [ + "退款率连续两周上升,华南区域增幅明显" + ], + "summary": "本月销售额比上月增长 18%,华东区域表现最好;订单量稳步提升,但退款率略有上升,建议重点关注华南区域的售后问题。" +} +``` + +导出接口: + +```http +POST /v1/workbench/ai/table-analyses/{record_id}/export +Idempotency-Key: +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "export_task_id": "aitask_export_1", + "status": "processing" + } +} +``` + +导出完成后返回: + +```json +{ + "code": 0, + "data": { + "file_id": "file_abc", + "file_name": "8月销售数据解读报告.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "file_size": 123456 + } +} +``` + +### 4.8 总结记录.png + +表格解读记录 + +功能说明: + +表格解读记录页标题为“总结记录”,顶部提供搜索框。列表按日期分组展示已分析文件,包括文件类型图标、标题、原始文件类型、时间、状态“已完成”和右侧箭头。 + +业务规则: + +1. 支持按标题、文件名搜索。 +2. 点击记录进入报告解读详情。 +3. 记录状态包括 `已完成`、`解读中`、`失败`、`已取消`。 +4. 文件被删除或过期时,报告记录仍可查看文本结果;原文件下载入口置灰。 + +建议接口: + +```http +GET /v1/workbench/ai/records?type=smart_table&query=&cursor=&limit=20 +``` + +## 5. AI 美文 + +AI 美文用于优化用户输入文案,使内容更通顺、正式、完整,可嵌入日报、月报等表单,也可作为独立文本润色能力复用。 + +### 5.1 AI美文.png + +AI美文 + +功能说明: + +页面标题为“AI美文”。上方展示“原文”输入框,示例内容为“今天下班后和朋友去吃火锅,聊了很多最近的事,感觉很开心,希望以后能经常聚聚。”,右下角显示 `0/300`。下方展示“AI优化的文案”,输出优化后的文案。底部提供“重新生成”和“使用文案”按钮。 + +业务规则: + +1. 原文最大 300 字。 +2. 原文为空时不允许生成。 +3. 点击“重新生成”基于同一原文重新调用 AI,并生成新版本。 +4. 点击“使用文案”将 AI 优化文案回填到来源表单字段。 +5. 若从日报/月报进入,返回时应保留原表单其他字段。 +6. AI 输出不得自动覆盖原文,必须由用户确认。 + +建议接口: + +```http +POST /v1/workbench/ai/text/polish +Idempotency-Key: +``` + +请求示例: + +```json +{ + "source": "daily_report", + "field": "summary", + "text": "今天下班后和朋友去吃火锅,聊了很多最近的事,感觉很开心,希望以后能经常聚聚。", + "style": "formal" +} +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_polish_1", + "status": "succeeded", + "result": { + "text": "下班后的快乐,是和朋友围坐在热气腾腾的火锅旁。聊聊近况,分享日常的小事,疲惫也在欢声笑语里慢慢散去。愿这样的相聚多一些,让平凡的日子多一点温暖。" + } + } +} +``` + +## 6. 日报 AI 接入 + +### 6.1 日报.png + +日报 + +功能说明: + +日报表单中增加“AI美文”按钮。页面包含日报起止时间、今日工作总结输入框和提交按钮。用户可在“今日工作总结”输入后点击“AI美文”进行文案优化。 + +业务规则: + +1. 日报原有提交逻辑保持不变。 +2. AI 美文只处理“今日工作总结”字段,不影响起止时间。 +3. 用户点击“使用文案”后,将优化文案回填到“今日工作总结”输入框。 +4. 若用户未输入今日工作总结,点击 AI 美文提示“请输入今日工作总结”。 +5. AI 优化后的日报仍需用户主动点击“提交”。 +6. 日报已有冲突规则时,AI 优化不绕过冲突校验。 + +建议和日报接口关系: + +```text +日报表单保存/提交仍使用原日报接口。 +AI 美文只提供文本优化,不直接提交日报。 +``` + +## 7. 月报 AI 接入 + +### 7.1 月报-发布中.png + +月报发布中 + +功能说明: + +月报发布页增加“AI美文”按钮。页面包含“本月工作总结”和“其他事项”两个输入框,均有 `0/1000` 字数限制,底部为提交按钮。 + +业务规则: + +1. `本月工作总结` 为必填字段,`其他事项` 可选。 +2. AI 美文默认优化当前聚焦或最近编辑的文本框;如无法判断,应弹出字段选择。 +3. 单字段最大 1000 字,但 AI 美文原型最大 300 字;首版建议: + - 日报/月报入口提交给 AI 时限制 300 字,超过时提示精简。 + - 或服务端支持 `max_chars=1000` 的办公文案优化模式。 +4. 使用 AI 文案后回填对应字段,不自动提交月报。 +5. 月报提交时仍执行原有必填、重复提交、月份唯一性等校验。 + +建议请求: + +```json +{ + "source": "monthly_report", + "field": "monthly_summary", + "text": "本月完成了工作台 AI 原型梳理和接口方案设计...", + "style": "formal" +} +``` + +## 8. 会议笔记 AI 总结 + +会议笔记 AI 总结基于会议基础信息、上传的会议笔记文件、人工笔记内容或语音识别文本生成会议总结。 + +### 8.1 会议笔记- AI总结.png + +会议笔记 AI总结 + +功能说明: + +会议详情页展示会议基础信息,包括会议主题、状态、发起人、时间、地点、会议记录员、参会人和会议时长。下方有页签“全部笔记(文件)”和“AI总结”。AI 总结内容包括: + +1. 会议结论 +2. 关键决策 +3. 后续安排表格 + +底部展示“由AI自动生成,如有不准确可手动编辑”,并提供“编辑”入口。 + +业务规则: + +1. 只有有会议笔记查看权限的用户可以查看 AI 总结。 +2. 会议记录员、发起人或具备笔记维护权限的用户可以触发 AI 总结生成和编辑。 +3. 普通参会人是否可查看 AI 总结由会议笔记权限决定。 +4. AI 总结需要绑定会议 ID。 +5. AI 总结来源应包括会议基础信息、会议笔记文件、人工补充内容和已有文件 OCR/文本解析结果。 +6. AI 总结结果必须允许人工编辑,编辑后保留编辑人和编辑时间。 +7. 如果会议没有任何笔记文件或文本来源,应提示“暂无可总结内容”。 + +建议接口: + +```http +POST /v1/workbench/meetings/{meeting_id}/ai-summary +Idempotency-Key: +``` + +请求示例: + +```json +{ + "source_file_ids": ["file_note_1", "file_note_2"], + "extra_text": "补充说明:预算方案需要下周前确认。" +} +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "task_id": "aitask_meeting_1", + "status": "processing" + } +} +``` + +查询会议 AI 总结: + +```http +GET /v1/workbench/meetings/{meeting_id}/ai-summary +``` + +响应示例: + +```json +{ + "code": 0, + "data": { + "summary_id": "msum_1", + "meeting_id": "mtg_1", + "status": "succeeded", + "sections": [ + { + "title": "会议结论", + "content": "本次会议围绕合作方案细节优化、时间节点调整及预算评估进行了讨论,并达成初步共识。" + }, + { + "title": "关键决策", + "items": [ + "整体合作方向确认,细节需要优化", + "第一阶段交付时间提前到 7月10日", + "市场部重新评估预算成本" + ] + } + ], + "actions": [ + {"item": "整理合作方案细节", "owner": "李明", "due_date": "2026-06-25"}, + {"item": "输出新的时间计划表", "owner": "张婷", "due_date": "2026-06-25"} + ], + "ai_generated": true, + "editable": true, + "updated_at": "2026-09-25T10:00:00Z" + } +} +``` + +编辑会议 AI 总结: + +```http +PUT /v1/workbench/meetings/{meeting_id}/ai-summary +Idempotency-Key: +``` + +业务规则: + +1. 编辑后 `ai_generated` 仍可保留为 true,但需要增加 `edited_by`、`edited_at`。 +2. 编辑内容需要版本控制,便于恢复和审计。 +3. 编辑不重新消耗 AI 调用额度。 + +## 9. AI 记录中心 + +### 9.1 记录类型 + +AI 记录包括: + +| 类型 | 来源 | 原型 | +| --- | --- | --- | +| `smart_document` | 智能文档生成 | 文档记录 | +| `smart_table` | 智能表格解读 | 总结记录 | +| `text_polish` | AI 美文 | 可选,不一定首版展示 | +| `meeting_summary` | 会议笔记 AI 总结 | 会议详情页 | + +### 9.2 通用记录字段 + +```json +{ + "record_id": "airec_1", + "type": "smart_table", + "title": "8月销售数据分析", + "status": "succeeded", + "display_status": "已完成", + "file_type": "xlsx", + "created_at": "2026-09-25T10:00:00Z", + "updated_at": "2026-09-25T10:01:00Z" +} +``` + +### 9.3 记录权限 + +1. 默认记录仅创建人可见。 +2. 会议 AI 总结按会议笔记权限可见。 +3. 后续如支持分享,需要显式分享给用户或群组,不默认企业内公开。 +4. 记录删除只删除工作台 AI 记录,不应直接删除原始文件,除非文件只被该记录引用且过期策略允许清理。 + +## 10. 服务端架构要求 + +### 10.1 模块划分 + +建议在 `im_workbench` 中增加 AI 业务模块: + +```text +internal/ai +internal/ai/provider +internal/ai/prompts +internal/ai/tasks +internal/httpapi/ai.go +internal/store/ai_mysql.go +``` + +职责: + +| 模块 | 职责 | +| --- | --- | +| HTTP API | 参数校验、鉴权、幂等、响应包装 | +| AI service | 编排任务、调用 provider、组织结果 | +| Provider | 对接具体大模型服务,封装重试、超时、错误 | +| Store | 保存任务、记录、结果、用量、导出文件 | +| Worker | 异步执行 AI 任务 | + +### 10.2 同步与异步 + +建议: + +1. AI 美文可以同步返回,但仍建议保存轻量任务记录。 +2. 智能文档可同步或短异步,首版建议异步,便于统一处理失败和重试。 +3. 智能表格必须异步,因为涉及文件解析和 AI 分析。 +4. 会议总结必须异步,因为涉及文件读取、OCR/文本抽取和总结生成。 + +### 10.3 队列 + +工作台服务当前已有 Kafka 配置,可用于 AI 任务异步执行;如果首版不引入 Kafka 消费,也可使用数据库任务表 + 后台 worker 轮询。 + +建议优先级: + +1. 简化首版:数据库任务表 + 本服务 worker。 +2. 后续增强:Kafka 投递 `workbench.ai.task.created`,worker 消费执行。 + +无论采用哪种方式,都需要: + +1. 幂等任务创建。 +2. 任务超时控制。 +3. 失败重试次数限制。 +4. 服务重启后可恢复 `pending/processing` 超时任务。 + +## 11. 数据库设计建议 + +### 11.1 AI 任务表 + +```sql +CREATE TABLE im_ai_tasks ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + task_id VARCHAR(64) NOT NULL, + task_type VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + source_type VARCHAR(32) NOT NULL DEFAULT '', + source_id VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL, + progress INT NOT NULL DEFAULT 0, + current_step VARCHAR(64) NOT NULL DEFAULT '', + input_json JSON NULL, + result_json JSON NULL, + error_code VARCHAR(64) NOT NULL DEFAULT '', + error_message VARCHAR(512) NOT NULL DEFAULT '', + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + started_at DATETIME NULL, + finished_at DATETIME NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uk_im_ai_tasks_task(task_id), + UNIQUE KEY uk_im_ai_tasks_idem(tenant_id,user_id,task_type,idempotency_key), + KEY idx_im_ai_tasks_user(tenant_id,user_id,task_type,created_at), + KEY idx_im_ai_tasks_status(status,updated_at) +); +``` + +### 11.2 AI 记录表 + +```sql +CREATE TABLE im_ai_records ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + record_id VARCHAR(64) NOT NULL, + task_id VARCHAR(64) NOT NULL DEFAULT '', + record_type VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + content_json JSON NULL, + source_file_id VARCHAR(128) NOT NULL DEFAULT '', + export_file_id VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME NULL, + UNIQUE KEY uk_im_ai_records_record(record_id), + KEY idx_im_ai_records_user(tenant_id,user_id,record_type,created_at), + KEY idx_im_ai_records_task(task_id) +); +``` + +### 11.3 AI 用量日志表 + +```sql +CREATE TABLE im_ai_usage_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + task_id VARCHAR(64) NOT NULL, + provider VARCHAR(64) NOT NULL, + model VARCHAR(128) NOT NULL, + prompt_tokens INT NOT NULL DEFAULT 0, + completion_tokens INT NOT NULL DEFAULT 0, + total_tokens INT NOT NULL DEFAULT 0, + latency_ms INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL, + error_code VARCHAR(128) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + KEY idx_im_ai_usage_tenant(tenant_id,created_at), + KEY idx_im_ai_usage_user(tenant_id,user_id,created_at), + KEY idx_im_ai_usage_task(task_id) +); +``` + +### 11.4 会议 AI 总结表 + +```sql +CREATE TABLE im_meeting_ai_summaries ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + summary_id VARCHAR(64) NOT NULL, + meeting_id VARCHAR(64) NOT NULL, + task_id VARCHAR(64) NOT NULL DEFAULT '', + content_json JSON NULL, + version INT NOT NULL DEFAULT 1, + ai_generated TINYINT(1) NOT NULL DEFAULT 1, + edited_by BIGINT NOT NULL DEFAULT 0, + edited_at DATETIME NULL, + created_by BIGINT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uk_im_meeting_ai_summary(summary_id), + UNIQUE KEY uk_im_meeting_ai_summary_meeting(tenant_id,meeting_id), + KEY idx_im_meeting_ai_summary_task(task_id) +); +``` + +## 12. AI Provider 配置 + +建议通过 manager 系统配置或 workbench 运行配置控制 AI Provider。 + +配置项建议: + +| 配置项 | 含义 | +| --- | --- | +| `ai.enabled` | 是否启用 AI | +| `ai.provider` | AI 服务商 | +| `ai.model.text` | 文本生成模型 | +| `ai.model.table` | 表格解读模型 | +| `ai.timeout_seconds` | 调用超时时间 | +| `ai.max_input_chars` | 文本最大输入长度 | +| `ai.table.max_file_size` | 表格最大文件大小 | +| `ai.table.max_rows` | 表格最大行数 | +| `ai.table.max_columns` | 表格最大列数 | +| `ai.daily_quota_per_user` | 单用户每日调用次数 | +| `ai.daily_quota_per_tenant` | 单租户每日调用次数 | + +要求: + +1. API Key 不允许下发到客户端。 +2. API Key 不允许写入 Git。 +3. Provider 调用失败需要脱敏记录错误。 +4. 支持按租户关闭 AI 能力。 + +## 13. 错误码建议 + +| HTTP | code | reason | 场景 | +| --- | --- | --- | --- | +| 400 | 30000 | `MALFORMED_REQUEST` | JSON 格式错误、缺少必填字段 | +| 400 | 30001 | `VALIDATION_FAILED` | 字数、文件类型、文件大小、行列数校验失败 | +| 401 | 30041 | `UNAUTHORIZED` | 未登录或 token 失效 | +| 403 | 30043 | `FORBIDDEN` | 无权限查看会议或记录 | +| 404 | 30044 | `RESOURCE_NOT_FOUND` | 任务、记录、文件不存在 | +| 409 | 30040 | `CONFLICT` | 幂等冲突或重复提交 | +| 429 | 30045 | `RATE_LIMITED` | AI 调用频控或额度耗尽 | +| 502 | 30100 | `AI_PROVIDER_FAILED` | AI 服务商调用失败 | +| 504 | 30101 | `AI_TIMEOUT` | AI 任务超时 | +| 422 | 30102 | `AI_CONTENT_REJECTED` | 输入或输出内容被安全策略拒绝 | +| 500 | 30103 | `AI_TASK_FAILED` | 任务执行失败 | + +错误响应示例: + +```json +{ + "code": 30001, + "reason": "VALIDATION_FAILED", + "message": "validation failed", + "details": { + "field": "source_text", + "max_length": 1000 + } +} +``` + +## 14. 前端交互要求 + +### 14.1 生成按钮状态 + +1. 输入为空时,生成按钮置灰或点击后提示必填。 +2. 生成中按钮进入 loading,不允许重复点击。 +3. 生成失败后恢复按钮,并展示失败原因。 +4. 使用文案/保存文档/导出报告成功后给出 Toast。 + +### 14.2 草稿保护 + +智能文档、日报、月报编辑中离开页面时,应保护用户输入: + +1. 返回前提示是否放弃编辑。 +2. 页面短暂切后台不应丢失输入。 +3. AI 生成失败不应清空原文。 + +### 14.3 记录刷新 + +1. 任务创建后立即进入处理页或结果页。 +2. 处理页轮询间隔建议 1~2 秒。 +3. 任务完成后停止轮询。 +4. 记录列表进入时刷新最新状态。 + +### 14.4 文件上传 + +1. 上传前校验文件类型。 +2. 上传中展示进度。 +3. 上传失败允许重试。 +4. 上传成功展示文件名、大小、类型和删除按钮。 + +## 15. 服务端验收标准 + +### 15.1 工作台入口 + +1. 首页能按配置展示智能文档和智能表格。 +2. 未启用 AI 时,AI 入口隐藏或置灰。 +3. 搜索应用可命中智能文档、智能表格、日报、会议笔记等应用。 +4. 无结果时返回空数组,前端展示“暂无匹配结果”。 + +### 15.2 智能文档 + +1. 输入 1~1000 字可创建生成任务。 +2. 超过 1000 字返回 `VALIDATION_FAILED`。 +3. 生成成功后返回标题和结构化正文。 +4. 重新生成会创建新版本或新任务。 +5. 保存文档后可在文档记录中查询。 + +### 15.3 智能表格 + +1. 支持上传 `xls`、`xlsx`、`csv`。 +2. 非法类型、超大文件、空文件返回明确错误。 +3. 创建分析任务后可查询进度。 +4. 分析完成后返回核心指标、数据预览、异常提醒和核心解读。 +5. 点击导出后生成可下载文件。 +6. 解读记录列表能查到已完成记录。 + +### 15.4 AI 美文 + +1. 原文为空不能生成。 +2. 文本超长返回明确错误。 +3. 生成结果不自动覆盖原文。 +4. 使用文案后能回填来源页面。 +5. 重新生成返回不同版本或刷新结果。 + +### 15.5 会议笔记 AI 总结 + +1. 有权限用户可生成会议 AI 总结。 +2. 无权限用户返回 `FORBIDDEN`。 +3. 无可总结内容时返回明确提示。 +4. 生成结果包含会议结论、关键决策和后续安排。 +5. 支持人工编辑,并保留编辑人和编辑时间。 + +## 16. 非功能要求 + +### 16.1 性能 + +1. AI 美文同步响应建议 10 秒内完成;超过 10 秒走异步或提示稍后查看。 +2. 智能文档生成建议 30 秒内完成。 +3. 智能表格解读建议 60 秒内完成,超时进入失败状态。 +4. 记录列表接口 P95 响应时间不超过 500 ms,不包含 AI 生成耗时。 + +### 16.2 稳定性 + +1. AI provider 异常不得影响 IM 聊天主链路。 +2. AI 任务失败不得造成文件丢失或日报/月报草稿丢失。 +3. 服务重启后,未完成任务可恢复或标记失败。 +4. 对 provider 做熔断和限流,避免拖垮 workbench 服务。 + +### 16.3 安全 + +1. AI 输入、输出和上传文件均按租户隔离。 +2. 不在日志中打印用户原文、AI 输出、文件内容、provider key。 +3. 导出文件下载必须鉴权。 +4. 上传文件必须做大小、MIME、扩展名和内容解析校验。 +5. 会议总结必须校验会议权限。 + +### 16.4 审计 + +需要记录: + +1. 谁发起 AI 任务。 +2. 发起时间、完成时间、耗时。 +3. 任务类型和来源。 +4. provider、model、token 用量。 +5. 成功或失败原因。 +6. 导出文件和下载行为。 + +## 17. 首版范围与后续扩展 + +### 17.1 首版必须实现 + +1. 工作台展示智能文档和智能表格入口。 +2. 工作台应用搜索。 +3. 智能文档生成、保存、记录列表。 +4. 智能表格上传、分析、进度、报告解读、记录列表。 +5. AI 美文用于日报和月报。 +6. 会议笔记 AI 总结生成和查看。 +7. 统一 AI 任务表、记录表、用量日志。 +8. 租户级 AI 开关和基础额度限制。 + +### 17.2 可后续扩展 + +1. AI 文档导出 Word/PDF。 +2. AI 表格多 sheet 选择。 +3. 解读报告分享给同事或群聊。 +4. 会议语音转写后自动总结。 +5. 按部门、角色配置 AI 使用额度。 +6. 管理后台查看 AI 使用统计。 +7. Prompt 模板后台配置。 +8. AI 结果人工反馈,用于优化提示词。 + diff --git a/docs/sql/IM-工作台AI建表SQL.sql b/docs/sql/IM-工作台AI建表SQL.sql new file mode 100644 index 0000000..da7cec1 --- /dev/null +++ b/docs/sql/IM-工作台AI建表SQL.sql @@ -0,0 +1,230 @@ +-- IM 工作台 AI 建表 SQL +-- Run on the shared meeting_im database. + +CREATE TABLE IF NOT EXISTS wb_ai_capability_config ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + tenant_code VARCHAR(64) NOT NULL DEFAULT '', + feature VARCHAR(64) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + model VARCHAR(64) NOT NULL DEFAULT 'deepseek-flash', + daily_limit INT NOT NULL DEFAULT 0, + concurrency_limit INT NOT NULL DEFAULT 1, + max_input_chars INT NOT NULL DEFAULT 0, + max_file_size_mb INT NOT NULL DEFAULT 0, + config_json JSON NULL, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_tenant_feature (tenant_id, feature) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS wb_ai_task ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id VARCHAR(64) NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + tenant_code VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + feature VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + progress INT NOT NULL DEFAULT 0, + source_type VARCHAR(32) NOT NULL DEFAULT '', + source_ref_id VARCHAR(128) NOT NULL DEFAULT '', + input_hash VARCHAR(128) NOT NULL DEFAULT '', + input_snapshot JSON NULL, + prompt_version VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(64) NOT NULL DEFAULT '', + result_ref_id VARCHAR(64) NOT NULL DEFAULT '', + result_json JSON NULL, + error_reason VARCHAR(64) NOT NULL DEFAULT '', + error_message VARCHAR(255) NOT NULL DEFAULT '', + retry_count INT NOT NULL DEFAULT 0, + started_at DATETIME(3) NULL, + finished_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_task_id (task_id), + KEY idx_user_feature_time (tenant_id, user_id, feature, created_at), + KEY idx_status_time (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS wb_ai_task_step ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id VARCHAR(64) NOT NULL, + step VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL, + progress INT NOT NULL DEFAULT 0, + error_reason VARCHAR(64) NOT NULL DEFAULT '', + started_at DATETIME(3) NULL, + finished_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_task_step (task_id, step) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS wb_ai_record ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + record_id VARCHAR(64) NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + tenant_code VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + record_type VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + summary VARCHAR(512) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL, + detail_id VARCHAR(64) NOT NULL DEFAULT '', + task_id VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_record_id (record_id), + KEY idx_user_type_time (tenant_id, user_id, record_type, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS wb_ai_document ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + record_id VARCHAR(64) NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id VARCHAR(64) NOT NULL, + document_type VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + content_markdown MEDIUMTEXT NOT NULL, + content_plain MEDIUMTEXT NULL, + sections_json JSON NULL, + source_excerpt VARCHAR(1024) NOT NULL DEFAULT '', + prompt_version VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_record_id (record_id), + KEY idx_tenant_user_time (tenant_id, user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS wb_ai_table_analysis ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + record_id VARCHAR(64) NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id VARCHAR(64) NOT NULL, + file_id VARCHAR(128) NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_type VARCHAR(16) NOT NULL, + file_size BIGINT UNSIGNED NOT NULL DEFAULT 0, + topic VARCHAR(128) NOT NULL, + metrics_json JSON NULL, + preview_json JSON NULL, + alerts_json JSON NULL, + interpretation MEDIUMTEXT NULL, + export_file_id VARCHAR(128) NOT NULL DEFAULT '', + prompt_version VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_record_id (record_id), + KEY idx_file_id (tenant_id, file_id), + KEY idx_tenant_user_time (tenant_id, user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS wb_ai_meeting_summary ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + summary_id VARCHAR(64) NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + tenant_code VARCHAR(64) NOT NULL, + meeting_id VARCHAR(128) NOT NULL, + user_id VARCHAR(64) NOT NULL, + task_id VARCHAR(64) NOT NULL DEFAULT '', + conclusions_json JSON NULL, + decisions_json JSON NULL, + action_items_json JSON NULL, + source_file_ids_json JSON NULL, + prompt_version VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(64) NOT NULL DEFAULT '', + edited_by VARCHAR(64) NOT NULL DEFAULT '', + edited_at DATETIME(3) NULL, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_summary_id (summary_id), + UNIQUE KEY uk_tenant_meeting (tenant_id, meeting_id), + KEY idx_user_time (tenant_id, user_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS wb_ai_usage_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + tenant_code VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + feature VARCHAR(64) NOT NULL, + task_id VARCHAR(64) NOT NULL DEFAULT '', + provider VARCHAR(32) NOT NULL, + model VARCHAR(64) NOT NULL, + provider_request_id VARCHAR(128) NOT NULL DEFAULT '', + prompt_tokens INT NOT NULL DEFAULT 0, + completion_tokens INT NOT NULL DEFAULT 0, + total_tokens INT NOT NULL DEFAULT 0, + latency_ms INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL, + error_reason VARCHAR(64) NOT NULL DEFAULT '', + input_hash VARCHAR(128) NOT NULL DEFAULT '', + output_hash VARCHAR(128) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + KEY idx_user_feature_day (tenant_id, user_id, feature, created_at), + KEY idx_provider_time (provider, model, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS wb_ai_idempotency_record ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id VARCHAR(64) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash VARCHAR(128) NOT NULL, + response_code INT NOT NULL DEFAULT 0, + response_body JSON NULL, + status VARCHAR(32) NOT NULL, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_idempotency (tenant_id, user_id, idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS wb_ai_prompt_template ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + template_key VARCHAR(64) NOT NULL, + version VARCHAR(64) NOT NULL, + provider VARCHAR(32) NOT NULL DEFAULT 'deepseek', + model VARCHAR(64) NOT NULL DEFAULT 'deepseek-flash', + system_prompt MEDIUMTEXT NOT NULL, + user_prompt_template MEDIUMTEXT NOT NULL, + response_schema JSON NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_template_version (template_key, version), + KEY idx_template_enabled (template_key, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO wb_ai_capability_config + (tenant_id, tenant_code, feature, enabled, model, daily_limit, concurrency_limit, max_input_chars, max_file_size_mb, config_json, created_at, updated_at) +VALUES + (0, '', 'smart_document', 0, 'deepseek-flash', 20, 1, 1000, 0, JSON_OBJECT('entry_title', '智能文档'), NOW(3), NOW(3)), + (0, '', 'smart_table', 0, 'deepseek-v4-pro', 10, 1, 100, 20, JSON_OBJECT('entry_title', '智能表格', 'allowed_file_types', JSON_ARRAY('xlsx', 'xls', 'csv')), NOW(3), NOW(3)), + (0, '', 'text_polish', 0, 'deepseek-flash', 50, 3, 300, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)), + (0, '', 'daily_report_polish', 0, 'deepseek-flash', 50, 3, 300, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)), + (0, '', 'monthly_report_polish', 0, 'deepseek-flash', 30, 3, 1000, 0, JSON_OBJECT('entry_title', 'AI美文'), NOW(3), NOW(3)), + (0, '', 'meeting_summary', 0, 'deepseek-v4-pro', 10, 1, 0, 0, JSON_OBJECT('entry_title', 'AI总结'), NOW(3), NOW(3)) +ON DUPLICATE KEY UPDATE + model = VALUES(model), + daily_limit = VALUES(daily_limit), + concurrency_limit = VALUES(concurrency_limit), + max_input_chars = VALUES(max_input_chars), + max_file_size_mb = VALUES(max_file_size_mb), + config_json = VALUES(config_json), + updated_at = NOW(3); diff --git a/go.mod b/go.mod index 38157d9..d9a931a 100644 --- a/go.mod +++ b/go.mod @@ -23,10 +23,17 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/richardlehane/mscfb v1.0.7 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/excelize/v2 v2.11.0 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/go.sum b/go.sum index 87b1d48..690c346 100644 --- a/go.sum +++ b/go.sum @@ -49,18 +49,30 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= +github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88= +github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= @@ -76,6 +88,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/internal/ai/provider.go b/internal/ai/provider.go new file mode 100644 index 0000000..8f14b8f --- /dev/null +++ b/internal/ai/provider.go @@ -0,0 +1,197 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "git.118im.com/im_team/im_workbench/internal/config" +) + +var ( + ErrNotConfigured = errors.New("ai provider is not configured") + ErrProvider = errors.New("ai provider failed") + ErrTimeout = errors.New("ai provider timeout") + ErrBadOutput = errors.New("ai provider returned invalid output") +) + +type Client struct { + cfg config.AIConfig + httpClient *http.Client +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type GenerateRequest struct { + Feature string + Model string + Messages []Message + Temperature float64 + MaxTokens int +} + +type GenerateResponse struct { + ProviderRequestID string + Model string + Content string + PromptTokens int + CompletionTokens int + TotalTokens int + FinishReason string + LatencyMS int64 +} + +func NewClient(cfg config.AIConfig) *Client { + timeout := time.Duration(cfg.DeepSeek.TimeoutMS) * time.Millisecond + if timeout <= 0 { + timeout = 60 * time.Second + } + return &Client{cfg: cfg, httpClient: &http.Client{Timeout: timeout}} +} + +func (c *Client) Configured() bool { + return c != nil && strings.TrimSpace(c.cfg.DeepSeek.APIKey) != "" +} + +func (c *Client) DefaultModel() string { + if c == nil || strings.TrimSpace(c.cfg.DefaultModel) == "" { + return "deepseek-flash" + } + return strings.TrimSpace(c.cfg.DefaultModel) +} + +func (c *Client) ComplexModel() string { + if c == nil || strings.TrimSpace(c.cfg.ComplexModel) == "" { + return c.DefaultModel() + } + return strings.TrimSpace(c.cfg.ComplexModel) +} + +func (c *Client) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) { + if c == nil || strings.TrimSpace(c.cfg.DeepSeek.APIKey) == "" { + return GenerateResponse{}, ErrNotConfigured + } + model := strings.TrimSpace(req.Model) + if model == "" { + model = c.DefaultModel() + } + temperature := req.Temperature + if temperature == 0 { + temperature = c.cfg.Temperature + } + if temperature == 0 { + temperature = 0.2 + } + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = c.cfg.MaxOutputTokens + } + if maxTokens <= 0 { + maxTokens = 4096 + } + body := map[string]any{ + "model": model, + "messages": req.Messages, + "temperature": temperature, + "max_tokens": maxTokens, + "stream": false, + } + payload, err := json.Marshal(body) + if err != nil { + return GenerateResponse{}, err + } + baseURL := strings.TrimRight(firstNonEmpty(c.cfg.DeepSeek.BaseURL, "https://api.deepseek.com"), "/") + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(payload)) + if err != nil { + return GenerateResponse{}, err + } + httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.cfg.DeepSeek.APIKey)) + httpReq.Header.Set("Content-Type", "application/json") + start := time.Now() + resp, err := c.httpClient.Do(httpReq) + if err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return GenerateResponse{}, ErrTimeout + } + return GenerateResponse{}, fmt.Errorf("%w: %v", ErrProvider, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusGatewayTimeout { + return GenerateResponse{}, ErrTimeout + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return GenerateResponse{}, fmt.Errorf("%w: deepseek status %d: %s", ErrProvider, resp.StatusCode, string(raw)) + } + var parsed chatCompletionResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return GenerateResponse{}, fmt.Errorf("%w: %v", ErrBadOutput, err) + } + if len(parsed.Choices) == 0 { + return GenerateResponse{}, ErrBadOutput + } + content := parsed.Choices[0].Message.Content + if strings.TrimSpace(content) == "" { + return GenerateResponse{}, ErrBadOutput + } + return GenerateResponse{ + ProviderRequestID: parsed.ID, + Model: firstNonEmpty(parsed.Model, model), + Content: content, + PromptTokens: parsed.Usage.PromptTokens, + CompletionTokens: parsed.Usage.CompletionTokens, + TotalTokens: parsed.Usage.TotalTokens, + FinishReason: parsed.Choices[0].FinishReason, + LatencyMS: time.Since(start).Milliseconds(), + }, nil +} + +func ExtractJSONObject(raw string) string { + text := strings.TrimSpace(raw) + if strings.HasPrefix(text, "```") { + text = strings.TrimPrefix(text, "```json") + text = strings.TrimPrefix(text, "```") + text = strings.TrimSuffix(text, "```") + text = strings.TrimSpace(text) + } + start := strings.Index(text, "{") + end := strings.LastIndex(text, "}") + if start >= 0 && end >= start { + return text[start : end+1] + } + return text +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +type chatCompletionResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Choices []struct { + FinishReason string `json:"finish_reason"` + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} diff --git a/internal/ai/service.go b/internal/ai/service.go new file mode 100644 index 0000000..f8d7d47 --- /dev/null +++ b/internal/ai/service.go @@ -0,0 +1,387 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +type Service struct { + client *Client +} + +type Usage struct { + ProviderRequestID string + Model string + PromptTokens int + CompletionTokens int + TotalTokens int + LatencyMS int64 +} + +type PolishResult struct { + Text string + Usage Usage +} + +type DocumentResult struct { + Title string + ContentMarkdown string + ContentPlain string + Sections []DocumentSection + Summary string + Usage Usage +} + +type DocumentSection struct { + Heading string `json:"heading"` + Content string `json:"content"` +} + +type TableAnalysisInput struct { + Topic string `json:"topic"` + FileID string `json:"file_id"` + FileName string `json:"file_name"` + FileType string `json:"file_type"` + FileSize int64 `json:"file_size"` + Profile TableProfile `json:"profile"` +} + +type TableAnalysisResult struct { + Title string + Metrics []TableMetric + Preview TablePreview + Alerts []TableAlert + Interpretation string + Summary string + Usage Usage +} + +type TableMetric struct { + Name string `json:"name"` + Value string `json:"value"` + ChangeText string `json:"change_text"` + Trend string `json:"trend"` +} + +type TablePreview struct { + Columns []string `json:"columns"` + Rows [][]string `json:"rows"` + TotalRows int `json:"total_rows"` +} + +type TableAlert struct { + Level string `json:"level"` + Title string `json:"title"` + Description string `json:"description"` +} + +type TableProfile struct { + Columns any `json:"columns,omitempty"` + SampleRows any `json:"sample_rows,omitempty"` + RowCount int `json:"row_count,omitempty"` + Stats any `json:"stats,omitempty"` + Outliers any `json:"outliers,omitempty"` + Parsed bool `json:"parsed"` + Message string `json:"message,omitempty"` +} + +type MeetingSummaryInput struct { + MeetingTitle string + StartAt string + EndAt string + Participants []string + Files []string +} + +type MeetingSummaryResult struct { + Conclusions []string + Decisions []string + ActionItems []MeetingActionItem + Usage Usage +} + +type MeetingActionItem struct { + OwnerName string `json:"owner_name"` + Content string `json:"content"` + DueText string `json:"due_text"` + Status string `json:"status"` +} + +func NewService(client *Client) *Service { + return &Service{client: client} +} + +func (s *Service) Configured() bool { + return s != nil && s.client != nil && s.client.Configured() +} + +func (s *Service) Polish(ctx context.Context, sourceText, scene, tone string, maxOutputChars int) (PolishResult, error) { + if s == nil || s.client == nil { + return PolishResult{}, ErrNotConfigured + } + if maxOutputChars <= 0 { + maxOutputChars = 300 + } + system := "你是 IM 工作台的企业办公文案助手。请只输出 JSON,不要输出 Markdown,不要输出额外解释。" + user := fmt.Sprintf(`请润色下面的中文工作文案。 + +要求: +1. 语气:%s。 +2. 场景:%s。 +3. 输出不超过 %d 个中文字符。 +4. 保持事实不变,不编造数据、人名、时间和结论。 +5. 只输出 JSON:{"polished_text":"..."}。 + +原文: +%s`, firstNonEmpty(tone, "professional"), firstNonEmpty(scene, "general"), maxOutputChars, sourceText) + resp, err := s.client.Generate(ctx, GenerateRequest{ + Feature: "text_polish", + Model: s.client.DefaultModel(), + Messages: []Message{ + {Role: "system", Content: system}, + {Role: "user", Content: user}, + }, + MaxTokens: maxOutputChars * 3, + }) + if err != nil { + return PolishResult{}, err + } + var out struct { + PolishedText string `json:"polished_text"` + } + if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { + return PolishResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) + } + out.PolishedText = strings.TrimSpace(out.PolishedText) + if out.PolishedText == "" { + return PolishResult{}, ErrBadOutput + } + return PolishResult{Text: out.PolishedText, Usage: usageFromResponse(resp)}, nil +} + +func (s *Service) GenerateDocument(ctx context.Context, sourceText, titleHint, documentType, language string) (DocumentResult, error) { + if s == nil || s.client == nil { + return DocumentResult{}, ErrNotConfigured + } + system := "你是 IM 工作台的企业办公文档助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。" + user := fmt.Sprintf(`请根据用户输入生成一份结构化办公文档。 + +要求: +1. 输出语言:%s。 +2. 文档类型:%s。 +3. 标题提示:%s。 +4. 内容要忠于原文,不编造事实。 +5. sections 建议 3 到 6 个,每个 section 包含 heading 和 content。 +6. content_markdown 要适合直接展示,使用二级标题和列表。 +7. 只输出 JSON,格式: +{ + "title": "string", + "summary": "string", + "content_markdown": "string", + "content_plain": "string", + "sections": [{"heading":"string","content":"string"}] +} + +用户输入: +%s`, firstNonEmpty(language, "zh-CN"), firstNonEmpty(documentType, "general"), strings.TrimSpace(titleHint), sourceText) + resp, err := s.client.Generate(ctx, GenerateRequest{ + Feature: "smart_document", + Model: s.client.DefaultModel(), + Messages: []Message{ + {Role: "system", Content: system}, + {Role: "user", Content: user}, + }, + MaxTokens: 2400, + }) + if err != nil { + return DocumentResult{}, err + } + var out struct { + Title string `json:"title"` + Summary string `json:"summary"` + ContentMarkdown string `json:"content_markdown"` + ContentPlain string `json:"content_plain"` + Sections []DocumentSection `json:"sections"` + } + if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { + return DocumentResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) + } + out.Title = strings.TrimSpace(firstNonEmpty(out.Title, titleHint, "智能文档")) + out.ContentMarkdown = strings.TrimSpace(out.ContentMarkdown) + if out.ContentMarkdown == "" && len(out.Sections) > 0 { + out.ContentMarkdown = markdownFromSections(out.Sections) + } + if strings.TrimSpace(out.ContentPlain) == "" { + out.ContentPlain = plainFromSections(out.Sections) + } + if out.ContentMarkdown == "" { + return DocumentResult{}, ErrBadOutput + } + return DocumentResult{ + Title: out.Title, + ContentMarkdown: out.ContentMarkdown, + ContentPlain: strings.TrimSpace(out.ContentPlain), + Sections: out.Sections, + Summary: strings.TrimSpace(out.Summary), + Usage: usageFromResponse(resp), + }, nil +} + +func (s *Service) AnalyzeTable(ctx context.Context, input TableAnalysisInput) (TableAnalysisResult, error) { + if s == nil || s.client == nil { + return TableAnalysisResult{}, ErrNotConfigured + } + system := "你是 IM 工作台的数据分析助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。" + profileJSON, _ := json.Marshal(input.Profile) + user := fmt.Sprintf(`请根据报表信息生成一份简洁的数据解读报告。 + +注意: +1. 若 profile.parsed=true,请优先依据 columns、sample_rows、stats、outliers 做分析。 +2. 若 profile.parsed=false,说明当前没有可用表格内容,只能基于文件元信息给出分析方向,不要编造具体真实数值。 + +报表主题:%s +文件名:%s +文件类型:%s +文件大小:%d bytes +表格画像:%s + +只输出 JSON: +{ + "title": "string", + "summary": "string", + "metrics": [{"name":"string","value":"string","change_text":"string","trend":"up|down|flat"}], + "preview": {"columns":["string"],"rows":[["string"]],"total_rows":0}, + "alerts": [{"level":"info|warning|danger","title":"string","description":"string"}], + "interpretation": "string" +}`, input.Topic, input.FileName, input.FileType, input.FileSize, string(profileJSON)) + resp, err := s.client.Generate(ctx, GenerateRequest{ + Feature: "smart_table", + Model: s.client.ComplexModel(), + Messages: []Message{ + {Role: "system", Content: system}, + {Role: "user", Content: user}, + }, + MaxTokens: 1800, + }) + if err != nil { + return TableAnalysisResult{}, err + } + var out struct { + Title string `json:"title"` + Summary string `json:"summary"` + Metrics []TableMetric `json:"metrics"` + Preview TablePreview `json:"preview"` + Alerts []TableAlert `json:"alerts"` + Interpretation string `json:"interpretation"` + } + if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { + return TableAnalysisResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) + } + out.Title = firstNonEmpty(out.Title, input.Topic, input.FileName, "智能解读报告") + if strings.TrimSpace(out.Interpretation) == "" { + return TableAnalysisResult{}, ErrBadOutput + } + return TableAnalysisResult{ + Title: out.Title, Metrics: out.Metrics, Preview: out.Preview, Alerts: out.Alerts, + Interpretation: strings.TrimSpace(out.Interpretation), Summary: strings.TrimSpace(out.Summary), Usage: usageFromResponse(resp), + }, nil +} + +func (s *Service) SummarizeMeeting(ctx context.Context, input MeetingSummaryInput) (MeetingSummaryResult, error) { + if s == nil || s.client == nil { + return MeetingSummaryResult{}, ErrNotConfigured + } + system := "你是 IM 工作台的会议纪要助手。请严格输出 JSON,不要输出 Markdown code fence,不要输出额外解释。" + payload, _ := json.Marshal(input) + user := fmt.Sprintf(`请根据会议上下文生成会议 AI 总结。 + +要求: +1. 不编造会议中没有出现的事实。 +2. 若附件内容不足,请基于标题和上下文给出谨慎总结。 +3. action_items 的 status 固定为 pending。 +4. 只输出 JSON: +{ + "conclusions": ["string"], + "decisions": ["string"], + "action_items": [{"owner_name":"string","content":"string","due_text":"string","status":"pending"}] +} + +会议上下文: +%s`, string(payload)) + resp, err := s.client.Generate(ctx, GenerateRequest{ + Feature: "meeting_summary", + Model: s.client.ComplexModel(), + Messages: []Message{ + {Role: "system", Content: system}, + {Role: "user", Content: user}, + }, + MaxTokens: 1800, + }) + if err != nil { + return MeetingSummaryResult{}, err + } + var out struct { + Conclusions []string `json:"conclusions"` + Decisions []string `json:"decisions"` + ActionItems []MeetingActionItem `json:"action_items"` + } + if err := json.Unmarshal([]byte(ExtractJSONObject(resp.Content)), &out); err != nil { + return MeetingSummaryResult{}, fmt.Errorf("%w: %v", ErrBadOutput, err) + } + if len(out.Conclusions) == 0 && len(out.Decisions) == 0 && len(out.ActionItems) == 0 { + return MeetingSummaryResult{}, ErrBadOutput + } + for i := range out.ActionItems { + if strings.TrimSpace(out.ActionItems[i].Status) == "" { + out.ActionItems[i].Status = "pending" + } + } + return MeetingSummaryResult{Conclusions: out.Conclusions, Decisions: out.Decisions, ActionItems: out.ActionItems, Usage: usageFromResponse(resp)}, nil +} + +func usageFromResponse(resp GenerateResponse) Usage { + return Usage{ + ProviderRequestID: resp.ProviderRequestID, + Model: resp.Model, + PromptTokens: resp.PromptTokens, + CompletionTokens: resp.CompletionTokens, + TotalTokens: resp.TotalTokens, + LatencyMS: resp.LatencyMS, + } +} + +func markdownFromSections(sections []DocumentSection) string { + var b strings.Builder + for _, section := range sections { + heading := strings.TrimSpace(section.Heading) + content := strings.TrimSpace(section.Content) + if heading == "" && content == "" { + continue + } + if heading != "" { + b.WriteString("## ") + b.WriteString(heading) + b.WriteString("\n") + } + if content != "" { + b.WriteString(content) + b.WriteString("\n\n") + } + } + return strings.TrimSpace(b.String()) +} + +func plainFromSections(sections []DocumentSection) string { + parts := make([]string, 0, len(sections)*2) + for _, section := range sections { + if heading := strings.TrimSpace(section.Heading); heading != "" { + parts = append(parts, heading) + } + if content := strings.TrimSpace(section.Content); content != "" { + parts = append(parts, content) + } + } + return strings.Join(parts, "\n") +} diff --git a/internal/app/app.go b/internal/app/app.go index a7ffd52..4c8ea3d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "git.118im.com/im_team/im_workbench/internal/ai" "git.118im.com/im_team/im_workbench/internal/auth" "git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/im_workbench/internal/events" @@ -57,11 +58,12 @@ func New(cfg *config.Config) (*App, error) { _ = st.Close() return nil, err } + aiService := ai.NewService(ai.NewClient(cfg.AI)) return &App{ store: st, redis: redisClient, events: publisher, - api: httpapi.New(st, auth.New(cfg.Auth, redisClient), publisher, cfg.Auth), + api: httpapi.New(st, auth.New(cfg.Auth, redisClient), publisher, cfg.Auth, aiService), }, nil } diff --git a/internal/config/config.go b/internal/config/config.go index a4f1c8d..b9ce6a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ type Config struct { Kafka KafkaConfig `json:"kafka"` Auth AuthConfig `json:"auth"` Manager ManagerConfig `json:"manager"` + AI AIConfig `json:"ai"` ReadTimeout time.Duration `json:"-"` WriteTimeout time.Duration `json:"-"` } @@ -70,6 +71,22 @@ type ManagerConfig struct { TimeoutMS int `json:"timeout_ms"` } +type AIConfig struct { + Provider string `json:"provider"` + DeepSeek DeepSeekConfig `json:"deepseek"` + DefaultModel string `json:"default_model"` + ComplexModel string `json:"complex_model"` + Temperature float64 `json:"temperature"` + MaxOutputTokens int `json:"max_output_tokens"` +} + +type DeepSeekConfig struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + TimeoutMS int `json:"timeout_ms"` + MaxRetries int `json:"max_retries"` +} + func Load() (*Config, error) { cfg := defaultConfig() if path := strings.TrimSpace(os.Getenv("IM_WORKBENCH_CONFIG")); path != "" { @@ -92,7 +109,7 @@ func defaultConfig() *Config { return &Config{ Listen: ":6061", MySQL: MySQLConfig{ - DSN: "root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true&charset=utf8mb4,utf8&loc=Local", + DSN: "root:root@tcp(127.0.0.1:3306)/workbench?parseTime=true&charset=utf8mb4,utf8&loc=Local", MaxOpenConns: 15, MaxIdleConns: 5, }, @@ -116,6 +133,18 @@ func defaultConfig() *Config { UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"}, TimeoutMS: 3000, }, + AI: AIConfig{ + Provider: "deepseek", + DefaultModel: "deepseek-flash", + ComplexModel: "deepseek-v4-pro", + Temperature: 0.2, + MaxOutputTokens: 4096, + DeepSeek: DeepSeekConfig{ + BaseURL: "https://api.deepseek.com", + TimeoutMS: 60000, + MaxRetries: 2, + }, + }, ReadTimeout: 8 * time.Second, WriteTimeout: 8 * time.Second, } @@ -144,6 +173,15 @@ func applyEnv(cfg *Config) { setString(&cfg.Manager.ServiceName, "IM_WORKBENCH_MANAGER_SERVICE_NAME") setString(&cfg.Manager.Secret, "IM_WORKBENCH_MANAGER_SECRET") setString(&cfg.Manager.UIDKeyGroup, "IM_WORKBENCH_MANAGER_UID_KEY_GROUP") + setString(&cfg.AI.Provider, "IM_WORKBENCH_AI_PROVIDER") + setString(&cfg.AI.DeepSeek.BaseURL, "DEEPSEEK_BASE_URL") + setString(&cfg.AI.DeepSeek.APIKey, "DEEPSEEK_API_KEY") + setString(&cfg.AI.DefaultModel, "DEEPSEEK_DEFAULT_MODEL") + setString(&cfg.AI.ComplexModel, "DEEPSEEK_COMPLEX_MODEL") + setInt(&cfg.AI.DeepSeek.TimeoutMS, "DEEPSEEK_TIMEOUT_MS") + setInt(&cfg.AI.DeepSeek.MaxRetries, "DEEPSEEK_MAX_RETRIES") + setInt(&cfg.AI.MaxOutputTokens, "DEEPSEEK_MAX_OUTPUT_TOKENS") + setFloat(&cfg.AI.Temperature, "DEEPSEEK_TEMPERATURE") if strings.TrimSpace(cfg.Auth.InternalSecret) == "" { setString(&cfg.Auth.InternalSecret, "IM_INTERNAL_API_SECRET") } @@ -200,7 +238,7 @@ func (c *Config) validate() error { return fmt.Errorf("mysql dsn is invalid: %w", err) } if strings.EqualFold(strings.TrimSpace(parsedDSN.DBName), "tinode") { - return errors.New("mysql database tinode is deprecated; use meeting_im") + return errors.New("mysql database tinode is deprecated; use workbench") } if strings.TrimSpace(c.Redis.Addr) == "" && !c.Auth.SkipSessionInDev { return errors.New("redis addr is required") @@ -211,6 +249,27 @@ func (c *Config) validate() error { if !strings.Contains(c.Auth.SessionKeyPattern, "%s") { return fmt.Errorf("session key pattern must include %%s") } + if strings.TrimSpace(c.AI.Provider) == "" { + c.AI.Provider = "deepseek" + } + if strings.TrimSpace(c.AI.DeepSeek.BaseURL) == "" { + c.AI.DeepSeek.BaseURL = "https://api.deepseek.com" + } + if c.AI.DeepSeek.TimeoutMS <= 0 { + c.AI.DeepSeek.TimeoutMS = 60000 + } + if strings.TrimSpace(c.AI.DefaultModel) == "" { + c.AI.DefaultModel = "deepseek-flash" + } + if strings.TrimSpace(c.AI.ComplexModel) == "" { + c.AI.ComplexModel = c.AI.DefaultModel + } + if c.AI.Temperature == 0 { + c.AI.Temperature = 0.2 + } + if c.AI.MaxOutputTokens <= 0 { + c.AI.MaxOutputTokens = 4096 + } return nil } @@ -237,6 +296,14 @@ func setBool(target *bool, key string) { } } +func setFloat(target *float64, key string) { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + if parsed, err := strconv.ParseFloat(value, 64); err == nil { + *target = parsed + } + } +} + func splitCSV(value string) []string { parts := strings.Split(value, ",") items := make([]string, 0, len(parts)) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2a73efd..7aced48 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -15,13 +15,13 @@ func TestValidateRejectsDeprecatedTinodeDatabase(t *testing.T) { } } -func TestDefaultConfigUsesMeetingIMDatabase(t *testing.T) { +func TestDefaultConfigUsesWorkbenchDatabase(t *testing.T) { cfg := defaultConfig() if err := cfg.validate(); err != nil { t.Fatalf("default config validation failed: %v", err) } - if !strings.Contains(cfg.MySQL.DSN, "/meeting_im?") { - t.Fatalf("default mysql dsn = %q, want meeting_im database", cfg.MySQL.DSN) + if !strings.Contains(cfg.MySQL.DSN, "/workbench?") { + t.Fatalf("default mysql dsn = %q, want workbench database", cfg.MySQL.DSN) } if cfg.MySQL.MaxOpenConns != 15 || cfg.MySQL.MaxIdleConns != 5 { t.Fatalf("default mysql pool = %d/%d, want 15/5", cfg.MySQL.MaxOpenConns, cfg.MySQL.MaxIdleConns) diff --git a/internal/httpapi/ai.go b/internal/httpapi/ai.go new file mode 100644 index 0000000..ab9b17c --- /dev/null +++ b/internal/httpapi/ai.go @@ -0,0 +1,1022 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "strings" + "time" + + "git.118im.com/im_team/im_workbench/internal/ai" + "git.118im.com/im_team/im_workbench/internal/auth" + "git.118im.com/im_team/im_workbench/internal/store" + "git.118im.com/im_team/im_workbench/internal/tableparse" +) + +const ( + reasonAINotConfigured = "AI_PROVIDER_NOT_CONFIGURED" + reasonAIProviderFailed = "AI_PROVIDER_FAILED" + reasonAITimeout = "AI_TIMEOUT" + reasonAITaskFailed = "AI_TASK_FAILED" + reasonAIFeatureClosed = "AI_FEATURE_DISABLED" +) + +func (a *API) aiCapabilities(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + caps, err := st.AICapabilities(r.Context()) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{ + "enabled": anyCapabilityEnabled(caps), + "features": aiCapabilityPayloads(caps), + }) +} + +func (a *API) aiTextPolish(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + if _, ok := requireIdempotencyKey(w, r); !ok { + return + } + var body aiPolishRequest + if !decodeBody(w, r, &body) { + return + } + feature := aiPolishFeature(body.Scene) + cap, ok := a.requireAICapability(w, r, st, feature) + if !ok { + return + } + source := strings.TrimSpace(body.SourceText) + maxInput := aiMaxInputChars(cap, 1000) + if source == "" || len([]rune(source)) > maxInput { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "source_text", "max_length": maxInput}) + return + } + maxOutput := body.MaxOutputChars + if maxOutput <= 0 { + maxOutput = 300 + } + result, err := a.ai.Polish(r.Context(), source, strings.TrimSpace(body.Scene), strings.TrimSpace(body.Tone), maxOutput) + if err != nil { + a.writeAIError(w, r, err) + _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ + TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: feature, + Provider: "deepseek", Status: "failed", ErrorReason: aiErrorReason(err), InputHash: hashText(source), + }) + return + } + _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ + TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: feature, + Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID, + PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, + LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(source), OutputHash: hashText(result.Text), + }) + writeSuccess(w, r, map[string]any{ + "source_text": source, + "polished_text": result.Text, + "usage": map[string]any{"remaining_today": 0}, + }) +} + +func (a *API) aiDocumentGenerate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + if _, ok := requireIdempotencyKey(w, r); !ok { + return + } + var body aiDocumentGenerateRequest + if !decodeBody(w, r, &body) { + return + } + cap, ok := a.requireAICapability(w, r, st, "smart_document") + if !ok { + return + } + source := strings.TrimSpace(body.SourceText) + maxInput := aiMaxInputChars(cap, 1000) + if source == "" || len([]rune(source)) > maxInput { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "source_text", "max_length": maxInput}) + return + } + taskID := "aitask_" + randomToken(12) + started := time.Now().UTC().Round(time.Millisecond) + if err := st.CreateAITask(r.Context(), store.AITask{ + TaskID: taskID, TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_document", + Status: "processing", Progress: 10, SourceType: "text", InputHash: hashText(source), + PromptVersion: "smart_document_v1", Model: "", + StartedAt: &started, + }, nil); err != nil { + writeStoreError(w, r, err) + return + } + result, err := a.ai.GenerateDocument(r.Context(), source, strings.TrimSpace(body.TitleHint), strings.TrimSpace(body.DocumentType), strings.TrimSpace(body.Language)) + if err != nil { + _ = st.UpdateAITaskResult(r.Context(), taskID, "failed", 100, "", nil, aiErrorReason(err), err.Error()) + _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ + TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_document", TaskID: taskID, + Provider: "deepseek", Status: "failed", ErrorReason: aiErrorReason(err), InputHash: hashText(source), + }) + a.writeAIError(w, r, err) + return + } + taskResult := map[string]any{ + "title": result.Title, + "summary": result.Summary, + "content_markdown": result.ContentMarkdown, + "content_plain": result.ContentPlain, + "sections": result.Sections, + "source_excerpt": excerpt(source, 200), + "can_save": true, + } + if err := st.UpdateAITaskResult(r.Context(), taskID, "succeeded", 100, "", taskResult, "", ""); err != nil { + writeStoreError(w, r, err) + return + } + _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ + TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_document", TaskID: taskID, + Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID, + PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, + LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(source), OutputHash: hashText(result.ContentMarkdown), + }) + writeSuccess(w, r, map[string]any{ + "task_id": taskID, + "status": "succeeded", + "polling_interval_ms": 1500, + "expires_at": time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339Nano), + }) +} + +func (a *API) aiTaskResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + taskID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/tasks/") + if !ok { + writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) + return + } + if tail == "cancel" && r.Method == http.MethodPost { + if _, ok := requireIdempotencyKey(w, r); !ok { + return + } + if err := st.CancelAITask(r.Context(), p.UserID, taskID); err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{"task_id": taskID, "status": "canceled", "canceled_at": time.Now().UTC().Format(time.RFC3339Nano)}) + return + } + if tail != "" { + writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) + return + } + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + task, err := st.AITask(r.Context(), p.UserID, taskID) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, aiTaskPayload(task)) +} + +func (a *API) aiDocuments(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + switch r.Method { + case http.MethodGet: + items, next, err := st.AIDocuments(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50)) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, pagePayload(aiRecordPayloads(items), next)) + case http.MethodPost: + a.aiDocumentSave(w, r, p, st) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) aiDocumentSave(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + if _, ok := requireIdempotencyKey(w, r); !ok { + return + } + var body aiDocumentSaveRequest + if !decodeBody(w, r, &body) { + return + } + title := strings.TrimSpace(body.Title) + content := strings.TrimSpace(body.ContentMarkdown) + if title == "" || content == "" || strings.TrimSpace(body.TaskID) == "" { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil) + return + } + task, err := st.AITask(r.Context(), p.UserID, strings.TrimSpace(body.TaskID)) + if err != nil { + writeStoreError(w, r, err) + return + } + if task.Status != "succeeded" { + writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", map[string]any{"status": task.Status}) + return + } + recordID := "airec_doc_" + randomToken(10) + sectionsJSON := "[]" + if len(body.Sections) > 0 { + if data, err := json.Marshal(body.Sections); err == nil { + sectionsJSON = string(data) + } + } else if task.ResultJSON.Valid { + var result map[string]any + if json.Unmarshal([]byte(task.ResultJSON.String), &result) == nil { + if sections, ok := result["sections"]; ok { + if data, err := json.Marshal(sections); err == nil { + sectionsJSON = string(data) + } + } + } + } + if err := st.SaveAIDocument(r.Context(), p.TenantCode, p.UserID, store.AIRecord{ + RecordID: recordID, RecordType: "smart_document", Title: title, Summary: excerpt(firstNonEmpty(body.ContentPlain, content), 120), + Status: "succeeded", TaskID: task.TaskID, + }, store.AIDocument{ + RecordID: recordID, DocumentType: firstNonEmpty(body.DocumentType, "general"), Title: title, ContentMarkdown: content, + ContentPlain: strings.TrimSpace(body.ContentPlain), SectionsJSON: sectionsJSON, SourceExcerpt: "", PromptVersion: task.PromptVersion, Model: task.Model, + }); err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{ + "record_id": recordID, + "title": title, + "created_at": time.Now().UTC().Format(time.RFC3339Nano), + "updated_at": time.Now().UTC().Format(time.RFC3339Nano), + }) +} + +func (a *API) aiDocumentResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/documents/") + if !ok || tail != "" { + writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) + return + } + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + doc, err := st.AIDocument(r.Context(), p.UserID, recordID) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, aiDocumentPayload(doc)) +} + +func (a *API) aiRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + items, next, err := st.AIRecords(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("type")), strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50)) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, pagePayload(aiGenericRecordPayloads(items), next)) +} + +func (a *API) aiRecordResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/records/") + if !ok || tail != "" { + writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) + return + } + if r.Method != http.MethodDelete { + methodNotAllowed(w, r) + return + } + if err := st.DeleteAIRecord(r.Context(), p.UserID, recordID); err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{"record_id": recordID, "deleted": true}) +} + +func (a *API) aiTableAnalyses(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + switch r.Method { + case http.MethodGet: + items, next, err := st.AITableAnalyses(r.Context(), p.UserID, strings.TrimSpace(r.URL.Query().Get("query")), r.URL.Query().Get("cursor"), limit(r, 20, 50)) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, pagePayload(aiTableRecordPayloads(items), next)) + case http.MethodPost: + a.aiTableAnalysisCreate(w, r, p, st) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) aiTableAnalysisCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + if _, ok := requireIdempotencyKey(w, r); !ok { + return + } + var body aiTableAnalysisRequest + if !decodeBody(w, r, &body) { + return + } + cap, ok := a.requireAICapability(w, r, st, "smart_table") + if !ok { + return + } + fileID := strings.TrimSpace(body.FileID) + topic := strings.TrimSpace(body.Topic) + fileType := strings.ToLower(strings.TrimSpace(body.FileType)) + if fileID == "" || topic == "" || !allowedTableFileType(fileType) { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil) + return + } + maxInput := aiMaxInputChars(cap, 100) + if len([]rune(topic)) > maxInput { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "topic", "max_length": maxInput}) + return + } + fileMeta := st.AIFileMetadata(r.Context(), fileID) + fileSize := body.FileSize + if fileSize <= 0 && fileMeta.FileSize > 0 { + fileSize = fileMeta.FileSize + } + if cap.MaxFileSizeMB > 0 && fileSize > int64(cap.MaxFileSizeMB)*1024*1024 { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "file_size", "max_file_size_mb": cap.MaxFileSizeMB}) + return + } + taskID := "aitask_tbl_" + randomToken(12) + recordID := "airec_tbl_" + randomToken(10) + started := time.Now().UTC().Round(time.Millisecond) + if err := st.CreateAITask(r.Context(), store.AITask{ + TaskID: taskID, TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_table", + Status: "processing", Progress: 20, SourceType: "file", SourceRefID: fileID, InputHash: hashText(fileID + ":" + topic), + PromptVersion: "smart_table_v1", StartedAt: &started, + }, nil); err != nil { + writeStoreError(w, r, err) + return + } + profile := tableProfileForAI(resolveTableFilePath(fileID, fileMeta.Location), fileType) + result, err := a.ai.AnalyzeTable(r.Context(), ai.TableAnalysisInput{ + Topic: topic, FileID: fileID, FileName: strings.TrimSpace(body.FileName), FileType: fileType, FileSize: fileSize, Profile: profile, + }) + if err != nil { + _ = st.UpdateAITaskResult(r.Context(), taskID, "failed", 100, "", nil, aiErrorReason(err), err.Error()) + a.writeAIError(w, r, err) + return + } + metricsJSON := mustJSON(result.Metrics, "[]") + previewJSON := mustJSON(result.Preview, "{}") + alertsJSON := mustJSON(result.Alerts, "[]") + if err := st.SaveAITableAnalysis(r.Context(), p.TenantCode, p.UserID, store.AIRecord{ + RecordID: recordID, RecordType: "smart_table", Title: result.Title, Summary: excerpt(firstNonEmpty(result.Summary, result.Interpretation), 160), + Status: "succeeded", TaskID: taskID, + }, store.AITableAnalysis{ + RecordID: recordID, FileID: fileID, FileName: strings.TrimSpace(body.FileName), FileType: fileType, FileSize: fileSize, + Topic: topic, MetricsJSON: metricsJSON, PreviewJSON: previewJSON, AlertsJSON: alertsJSON, Interpretation: result.Interpretation, + PromptVersion: "smart_table_v1", Model: result.Usage.Model, + }); err != nil { + writeStoreError(w, r, err) + return + } + taskResult := map[string]any{"record_id": recordID, "title": result.Title, "metrics": result.Metrics, "alerts": result.Alerts, "interpretation": result.Interpretation} + if err := st.UpdateAITaskResult(r.Context(), taskID, "succeeded", 100, recordID, taskResult, "", ""); err != nil { + writeStoreError(w, r, err) + return + } + _ = st.InsertAIUsageLog(r.Context(), store.AIUsageLog{ + TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "smart_table", TaskID: taskID, + Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID, + PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens, + LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(fileID + ":" + topic), OutputHash: hashText(result.Interpretation), + }) + writeSuccess(w, r, map[string]any{"task_id": taskID, "record_id": recordID, "status": "succeeded", "polling_interval_ms": 1500}) +} + +func (a *API) aiTableAnalysisResource(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + recordID, tail, ok := resourceTail(r.URL.Path, "/v1/workbench/ai/table-analyses/") + if !ok { + writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil) + return + } + if tail == "export" && r.Method == http.MethodPost { + if _, ok := requireIdempotencyKey(w, r); !ok { + return + } + if _, err := st.AITableAnalysis(r.Context(), p.UserID, recordID); err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, map[string]any{ + "file_id": "fil_export_" + randomToken(8), + "file_name": recordID + "-解读报告.docx", + "download_url": "", + "expires_at": time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339Nano), + }) + return + } + if tail != "" || r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + item, err := st.AITableAnalysis(r.Context(), p.UserID, recordID) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, aiTableAnalysisPayload(item)) +} + +func (a *API) aiMeetingSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { + switch r.Method { + case http.MethodGet: + if _, err := st.Meeting(r.Context(), p.UserID, meetingID); err != nil { + writeStoreError(w, r, err) + return + } + summary, err := st.AIMeetingSummary(r.Context(), p.UserID, meetingID) + if errors.Is(err, store.ErrNotFound) { + writeSuccess(w, r, map[string]any{"meeting_id": meetingID, "status": "empty"}) + return + } + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, aiMeetingSummaryPayload(summary)) + case http.MethodPost: + a.aiMeetingSummaryCreate(w, r, p, st, meetingID) + case http.MethodPut: + a.aiMeetingSummaryUpdate(w, r, p, st, meetingID) + default: + methodNotAllowed(w, r) + } +} + +func (a *API) aiMeetingSummaryCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { + if _, ok := requireIdempotencyKey(w, r); !ok { + return + } + var body aiMeetingSummaryRequest + if !decodeBody(w, r, &body) { + return + } + if _, ok := a.requireAICapability(w, r, st, "meeting_summary"); !ok { + return + } + meeting, err := st.Meeting(r.Context(), p.UserID, meetingID) + if err != nil { + writeStoreError(w, r, err) + return + } + files, err := st.MeetingNoteFiles(r.Context(), p.UserID, meetingID) + if err != nil { + writeStoreError(w, r, err) + return + } + fileNames, fileIDs := meetingSummaryFiles(files, body.FileIDs) + taskID := "aitask_mtg_" + randomToken(12) + summaryID := "aisum_" + randomToken(10) + started := time.Now().UTC().Round(time.Millisecond) + if err := st.CreateAITask(r.Context(), store.AITask{ + TaskID: taskID, TenantCode: p.TenantCode, UserID: formatUserID(p.UserID), Feature: "meeting_summary", + Status: "processing", Progress: 20, SourceType: "meeting", SourceRefID: meetingID, InputHash: hashText(meetingID + strings.Join(fileIDs, ",")), + PromptVersion: "meeting_summary_v1", StartedAt: &started, + }, nil); err != nil { + writeStoreError(w, r, err) + return + } + result, err := a.ai.SummarizeMeeting(r.Context(), ai.MeetingSummaryInput{ + MeetingTitle: meeting.Title, StartAt: meeting.StartAt.UTC().Format(time.RFC3339Nano), EndAt: meeting.EndAt.UTC().Format(time.RFC3339Nano), + Participants: meetingSummaryParticipants(*meeting), Files: fileNames, + }) + if err != nil { + _ = st.UpdateAITaskResult(r.Context(), taskID, "failed", 100, "", nil, aiErrorReason(err), err.Error()) + a.writeAIError(w, r, err) + return + } + if err := st.UpsertAIMeetingSummary(r.Context(), p.TenantCode, p.UserID, store.AIMeetingSummary{ + SummaryID: summaryID, MeetingID: meetingID, TaskID: taskID, + ConclusionsJSON: mustJSON(result.Conclusions, "[]"), DecisionsJSON: mustJSON(result.Decisions, "[]"), + ActionItemsJSON: mustJSON(result.ActionItems, "[]"), SourceFileIDsJSON: mustJSON(fileIDs, "[]"), + PromptVersion: "meeting_summary_v1", Model: result.Usage.Model, + }); err != nil { + writeStoreError(w, r, err) + return + } + _ = st.UpdateAITaskResult(r.Context(), taskID, "succeeded", 100, summaryID, map[string]any{ + "summary_id": summaryID, "meeting_id": meetingID, "conclusions": result.Conclusions, "decisions": result.Decisions, "action_items": result.ActionItems, + }, "", "") + writeSuccess(w, r, map[string]any{"task_id": taskID, "meeting_id": meetingID, "status": "succeeded", "polling_interval_ms": 1500}) +} + +func (a *API) aiMeetingSummaryUpdate(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store, meetingID string) { + if _, ok := requireIdempotencyKey(w, r); !ok { + return + } + var body aiMeetingSummaryUpdateRequest + if !decodeBody(w, r, &body) { + return + } + summaryID := strings.TrimSpace(body.SummaryID) + if summaryID == "" { + writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", map[string]any{"field": "summary_id"}) + return + } + summary, err := st.UpdateAIMeetingSummary(r.Context(), p.UserID, meetingID, summaryID, mustJSON(body.Conclusions, "[]"), mustJSON(body.Decisions, "[]"), mustJSON(body.ActionItems, "[]")) + if err != nil { + writeStoreError(w, r, err) + return + } + writeSuccess(w, r, aiMeetingSummaryPayload(summary)) +} + +func (a *API) workbenchApps(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + modules, _, err := st.WorkbenchModules(r.Context(), p.UserID) + if err != nil { + writeStoreError(w, r, err) + return + } + apps := workbenchAppPayloads(modules) + caps, err := st.AICapabilities(r.Context()) + if err == nil { + apps = append(apps, aiAppPayloads(caps)...) + } + writeSuccess(w, r, map[string]any{"common_apps": apps, "all_apps": apps}) +} + +func (a *API) workbenchAppsSearch(w http.ResponseWriter, r *http.Request, p *auth.Principal, st *store.Store) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query == "" { + writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "q"}) + return + } + modules, _, err := st.WorkbenchModules(r.Context(), p.UserID) + if err != nil { + writeStoreError(w, r, err) + return + } + apps := workbenchAppPayloads(modules) + if caps, err := st.AICapabilities(r.Context()); err == nil { + apps = append(apps, aiAppPayloads(caps)...) + } + items := make([]map[string]any, 0) + for _, app := range apps { + name, _ := app["name"].(string) + if strings.Contains(strings.ToLower(name), strings.ToLower(query)) { + app["matched_text"] = name + items = append(items, app) + } + } + writeSuccess(w, r, map[string]any{"items": items, "has_more": false}) +} + +func (a *API) writeAIError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, ai.ErrNotConfigured): + writeError(w, r, http.StatusServiceUnavailable, 30050, reasonAINotConfigured, "ai provider not configured", nil) + case errors.Is(err, ai.ErrTimeout): + writeError(w, r, http.StatusGatewayTimeout, 30051, reasonAITimeout, "ai timeout", nil) + case errors.Is(err, ai.ErrBadOutput): + writeError(w, r, http.StatusBadGateway, 30052, reasonAITaskFailed, "ai task failed", nil) + default: + writeError(w, r, http.StatusBadGateway, 30053, reasonAIProviderFailed, "ai provider failed", nil) + } +} + +func aiErrorReason(err error) string { + switch { + case errors.Is(err, ai.ErrNotConfigured): + return reasonAINotConfigured + case errors.Is(err, ai.ErrTimeout): + return reasonAITimeout + case errors.Is(err, ai.ErrBadOutput): + return reasonAITaskFailed + default: + return reasonAIProviderFailed + } +} + +func (a *API) requireAICapability(w http.ResponseWriter, r *http.Request, st *store.Store, feature string) (store.AICapability, bool) { + caps, err := st.AICapabilities(r.Context()) + if err != nil { + writeStoreError(w, r, err) + return store.AICapability{}, false + } + for _, cap := range caps { + if cap.Feature != feature { + continue + } + if !cap.Enabled { + writeError(w, r, http.StatusForbidden, 30055, reasonAIFeatureClosed, "ai feature disabled", map[string]any{"feature": feature}) + return store.AICapability{}, false + } + return cap, true + } + writeError(w, r, http.StatusForbidden, 30055, reasonAIFeatureClosed, "ai feature disabled", map[string]any{"feature": feature}) + return store.AICapability{}, false +} + +func aiPolishFeature(scene string) string { + switch strings.TrimSpace(scene) { + case "daily_report": + return "daily_report_polish" + case "monthly_report": + return "monthly_report_polish" + default: + return "text_polish" + } +} + +func aiMaxInputChars(cap store.AICapability, fallback int) int { + if cap.MaxInputChars > 0 { + return cap.MaxInputChars + } + return fallback +} + +type aiPolishRequest struct { + SourceText string `json:"source_text"` + Scene string `json:"scene"` + Tone string `json:"tone"` + MaxOutputChars int `json:"max_output_chars"` +} + +type aiDocumentGenerateRequest struct { + SourceText string `json:"source_text"` + TitleHint string `json:"title_hint"` + DocumentType string `json:"document_type"` + Language string `json:"language"` +} + +type aiDocumentSaveRequest struct { + TaskID string `json:"task_id"` + Title string `json:"title"` + DocumentType string `json:"document_type"` + ContentMarkdown string `json:"content_markdown"` + ContentPlain string `json:"content_plain"` + Sections []ai.DocumentSection `json:"sections"` +} + +type aiTableAnalysisRequest struct { + FileID string `json:"file_id"` + FileName string `json:"file_name"` + FileType string `json:"file_type"` + FileSize int64 `json:"file_size"` + Topic string `json:"topic"` + SheetName string `json:"sheet_name"` + Language string `json:"language"` +} + +type aiMeetingSummaryRequest struct { + FileIDs []string `json:"file_ids"` + SummaryType string `json:"summary_type"` + ForceRegenerate bool `json:"force_regenerate"` +} + +type aiMeetingSummaryUpdateRequest struct { + SummaryID string `json:"summary_id"` + Conclusions []string `json:"conclusions"` + Decisions []string `json:"decisions"` + ActionItems []ai.MeetingActionItem `json:"action_items"` +} + +func aiCapabilityPayloads(caps []store.AICapability) []map[string]any { + items := make([]map[string]any, 0, len(caps)) + for _, cap := range caps { + cfg := jsonObject(cap.ConfigJSON) + title, _ := cfg["entry_title"].(string) + items = append(items, map[string]any{ + "feature": cap.Feature, + "enabled": cap.Enabled, + "entry_title": firstNonEmpty(title, cap.Feature), + "daily_limit": cap.DailyLimit, + "remaining_today": cap.DailyLimit, + "max_input_chars": cap.MaxInputChars, + "max_file_size_mb": cap.MaxFileSizeMB, + "model": cap.Model, + }) + } + return items +} + +func anyCapabilityEnabled(caps []store.AICapability) bool { + for _, cap := range caps { + if cap.Enabled { + return true + } + } + return false +} + +func aiTaskPayload(task *store.AITask) map[string]any { + var result any + if task.ResultJSON.Valid && strings.TrimSpace(task.ResultJSON.String) != "" { + var decoded any + if json.Unmarshal([]byte(task.ResultJSON.String), &decoded) == nil { + result = decoded + } + } + var errPayload any + if task.ErrorReason != "" { + errPayload = map[string]any{"reason": task.ErrorReason, "message": task.ErrorMessage} + } + return map[string]any{ + "task_id": task.TaskID, + "feature": task.Feature, + "status": task.Status, + "progress": task.Progress, + "steps": []any{}, + "result": result, + "error": errPayload, + } +} + +func aiRecordPayloads(records []store.AIRecord) []map[string]any { + items := make([]map[string]any, 0, len(records)) + for _, record := range records { + items = append(items, map[string]any{ + "record_id": record.RecordID, + "title": record.Title, + "summary": record.Summary, + "document_type": "general", + "status": record.Status, + "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), + }) + } + return items +} + +func aiTableRecordPayloads(records []store.AIRecord) []map[string]any { + items := make([]map[string]any, 0, len(records)) + for _, record := range records { + items = append(items, map[string]any{ + "record_id": record.RecordID, + "title": record.Title, + "summary": record.Summary, + "status": record.Status, + "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), + }) + } + return items +} + +func aiGenericRecordPayloads(records []store.AIRecord) []map[string]any { + items := make([]map[string]any, 0, len(records)) + for _, record := range records { + items = append(items, map[string]any{ + "record_id": record.RecordID, + "type": record.RecordType, + "title": record.Title, + "summary": record.Summary, + "status": record.Status, + "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano), + "route": aiRecordRoute(record), + }) + } + return items +} + +func aiRecordRoute(record store.AIRecord) string { + switch record.RecordType { + case "smart_document": + return "/workbench/ai/document/" + record.RecordID + case "smart_table": + return "/workbench/ai/table/" + record.RecordID + default: + return "" + } +} + +func aiDocumentPayload(doc *store.AIDocument) map[string]any { + var sections any = []any{} + if strings.TrimSpace(doc.SectionsJSON) != "" { + var decoded any + if json.Unmarshal([]byte(doc.SectionsJSON), &decoded) == nil { + sections = decoded + } + } + return map[string]any{ + "record_id": doc.RecordID, + "title": doc.Title, + "document_type": doc.DocumentType, + "content_markdown": doc.ContentMarkdown, + "content_plain": doc.ContentPlain, + "sections": sections, + "source_excerpt": doc.SourceExcerpt, + "created_at": doc.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": doc.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func aiTableAnalysisPayload(item *store.AITableAnalysis) map[string]any { + return map[string]any{ + "record_id": item.RecordID, + "title": item.Topic, + "file": map[string]any{ + "file_id": item.FileID, "file_name": item.FileName, "file_type": item.FileType, "file_size": item.FileSize, + }, + "metrics": decodeJSONAny(item.MetricsJSON, []any{}), + "preview": decodeJSONAny(item.PreviewJSON, map[string]any{}), + "alerts": decodeJSONAny(item.AlertsJSON, []any{}), + "interpretation": item.Interpretation, + "export_options": []string{"docx", "pdf"}, + "created_at": item.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": item.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func aiMeetingSummaryPayload(item *store.AIMeetingSummary) map[string]any { + payload := map[string]any{ + "meeting_id": item.MeetingID, + "summary_id": item.SummaryID, + "status": "succeeded", + "conclusions": decodeJSONAny(item.ConclusionsJSON, []any{}), + "decisions": decodeJSONAny(item.DecisionsJSON, []any{}), + "action_items": decodeJSONAny(item.ActionItemsJSON, []any{}), + "editable": true, + "version": item.Version, + "updated_at": item.UpdatedAt.UTC().Format(time.RFC3339Nano), + } + if item.EditedAt != nil { + payload["edited_at"] = item.EditedAt.UTC().Format(time.RFC3339Nano) + payload["edited_by"] = item.EditedBy + } + return payload +} + +func meetingSummaryFiles(files []store.MeetingNoteFile, requested []string) ([]string, []string) { + filter := map[string]bool{} + for _, id := range requested { + id = strings.TrimSpace(id) + if id != "" { + filter[id] = true + } + } + names := make([]string, 0, len(files)) + ids := make([]string, 0, len(files)) + for _, file := range files { + if len(filter) > 0 && !filter[file.FileID] { + continue + } + names = append(names, file.FileName) + ids = append(ids, file.FileID) + } + return names, ids +} + +func meetingSummaryParticipants(meeting store.Meeting) []string { + items := make([]string, 0, len(meeting.Participants)+1) + if meeting.Organizer.DisplayName != "" { + items = append(items, meeting.Organizer.DisplayName) + } + for _, participant := range meeting.Participants { + name := participant.User.DisplayName + if name == "" { + name = formatUserID(participant.UserID) + } + items = append(items, name) + } + return items +} + +func resolveTableFilePath(fileID, location string) string { + candidates := []string{strings.TrimSpace(location), strings.TrimSpace(fileID)} + for _, candidate := range candidates { + if candidate == "" || strings.HasPrefix(candidate, "http://") || strings.HasPrefix(candidate, "https://") { + continue + } + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + return "" +} + +func tableProfileForAI(path, fileType string) ai.TableProfile { + if strings.TrimSpace(path) == "" { + return ai.TableProfile{Parsed: false, Message: "source file is not available locally"} + } + profile, err := tableparse.Parse(path, fileType) + if err != nil { + return ai.TableProfile{Parsed: false, Message: err.Error()} + } + return ai.TableProfile{ + Columns: profile.Columns, + SampleRows: profile.SampleRows, + RowCount: profile.RowCount, + Stats: profile.Stats, + Outliers: profile.Outliers, + Parsed: profile.Parsed, + Message: profile.Message, + } +} + +func allowedTableFileType(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "xlsx", "xls", "csv": + return true + default: + return false + } +} + +func mustJSON(value any, fallback string) string { + data, err := json.Marshal(value) + if err != nil || len(data) == 0 || string(data) == "null" { + return fallback + } + return string(data) +} + +func decodeJSONAny(raw string, fallback any) any { + if strings.TrimSpace(raw) == "" { + return fallback + } + var out any + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return fallback + } + return out +} + +func workbenchAppPayloads(modules []store.WorkbenchModule) []map[string]any { + items := make([]map[string]any, 0, len(modules)) + for _, module := range modules { + items = append(items, map[string]any{ + "app_id": module.Code, + "name": module.Name, + "icon": module.IconFileID, + "route": module.Route, + "type": "builtin", + "enabled": module.Enabled, + }) + } + return items +} + +func aiAppPayloads(caps []store.AICapability) []map[string]any { + routes := map[string]string{ + "smart_document": "/workbench/ai/document", + "smart_table": "/workbench/ai/table", + } + items := make([]map[string]any, 0, len(caps)) + for _, cap := range caps { + route := routes[cap.Feature] + if route == "" { + continue + } + cfg := jsonObject(cap.ConfigJSON) + title, _ := cfg["entry_title"].(string) + items = append(items, map[string]any{ + "app_id": cap.Feature, + "name": firstNonEmpty(title, cap.Feature), + "icon": "ai", + "route": route, + "type": "ai", + "enabled": cap.Enabled, + }) + } + return items +} + +func hashText(text string) string { + sum := sha256.Sum256([]byte(text)) + return hex.EncodeToString(sum[:]) +} + +func excerpt(text string, maxRunes int) string { + runes := []rune(strings.TrimSpace(text)) + if len(runes) <= maxRunes { + return string(runes) + } + return string(runes[:maxRunes]) +} diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index aeb9b1b..e22e2da 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "git.118im.com/im_team/im_workbench/internal/ai" "git.118im.com/im_team/im_workbench/internal/auth" "git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/im_workbench/internal/events" @@ -23,6 +24,7 @@ type API struct { auth *auth.Authenticator events events.Publisher authCfg config.AuthConfig + ai *ai.Service } const workbenchEventPublishTimeout = 15 * time.Second @@ -32,15 +34,27 @@ const ( maxMailboxContentLength = 5000 ) -func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher, authCfg config.AuthConfig) *API { +func New(st *store.Store, authenticator *auth.Authenticator, publisher events.Publisher, authCfg config.AuthConfig, aiService *ai.Service) *API { if publisher == nil { publisher = events.NoopPublisher{} } - return &API{store: st, auth: authenticator, events: publisher, authCfg: authCfg} + return &API{store: st, auth: authenticator, events: publisher, authCfg: authCfg, ai: aiService} } func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("/v1/workbench", a.withAuth(a.workbench)) + mux.HandleFunc("/v1/workbench/apps", a.withAuth(a.workbenchApps)) + mux.HandleFunc("/v1/workbench/apps/search", a.withAuth(a.workbenchAppsSearch)) + mux.HandleFunc("/v1/workbench/ai/capabilities", a.withAuth(a.aiCapabilities)) + mux.HandleFunc("/v1/workbench/ai/text/polish", a.withAuth(a.aiTextPolish)) + mux.HandleFunc("/v1/workbench/ai/documents/generate", a.withAuth(a.aiDocumentGenerate)) + mux.HandleFunc("/v1/workbench/ai/documents", a.withAuth(a.aiDocuments)) + mux.HandleFunc("/v1/workbench/ai/documents/", a.withAuth(a.aiDocumentResource)) + mux.HandleFunc("/v1/workbench/ai/table-analyses", a.withAuth(a.aiTableAnalyses)) + mux.HandleFunc("/v1/workbench/ai/table-analyses/", a.withAuth(a.aiTableAnalysisResource)) + mux.HandleFunc("/v1/workbench/ai/records", a.withAuth(a.aiRecords)) + mux.HandleFunc("/v1/workbench/ai/records/", a.withAuth(a.aiRecordResource)) + mux.HandleFunc("/v1/workbench/ai/tasks/", a.withAuth(a.aiTaskResource)) mux.HandleFunc("/v1/workbench/organization/departments", a.withAuth(a.organizationDepartments)) mux.HandleFunc("/v1/workbench/organization/departments/", a.withAuth(a.organizationDepartmentResource)) mux.HandleFunc("/v1/workbench/organization/members", a.withAuth(a.organizationMembers)) @@ -518,6 +532,8 @@ func (a *API) meetingResource(w http.ResponseWriter, r *http.Request, p *auth.Pr writeSuccess(w, r, meetingPayload(*meeting, p.UserID)) case tail == "notes" && r.Method == http.MethodGet: a.meetingNotesDetail(w, r, p, st, meetingID) + case tail == "ai-summary": + a.aiMeetingSummary(w, r, p, st, meetingID) case tail == "notes/files" && r.Method == http.MethodPost: a.meetingNoteFiles(w, r, p, st, meetingID) case strings.HasPrefix(tail, "notes/files/"): diff --git a/internal/store/ai.go b/internal/store/ai.go new file mode 100644 index 0000000..4c1584d --- /dev/null +++ b/internal/store/ai.go @@ -0,0 +1,459 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "strconv" + "strings" + "time" +) + +type AICapability struct { + Feature string `db:"feature"` + Enabled bool `db:"enabled"` + Model string `db:"model"` + DailyLimit int `db:"daily_limit"` + ConcurrencyLimit int `db:"concurrency_limit"` + MaxInputChars int `db:"max_input_chars"` + MaxFileSizeMB int `db:"max_file_size_mb"` + ConfigJSON string `db:"config_json"` +} + +type AITask struct { + TaskID string `db:"task_id"` + TenantCode string `db:"tenant_code"` + UserID string `db:"user_id"` + Feature string `db:"feature"` + Status string `db:"status"` + Progress int `db:"progress"` + SourceType string `db:"source_type"` + SourceRefID string `db:"source_ref_id"` + InputHash string `db:"input_hash"` + PromptVersion string `db:"prompt_version"` + Model string `db:"model"` + ResultRefID string `db:"result_ref_id"` + ResultJSON sql.NullString `db:"result_json"` + ErrorReason string `db:"error_reason"` + ErrorMessage string `db:"error_message"` + RetryCount int `db:"retry_count"` + StartedAt *time.Time `db:"started_at"` + FinishedAt *time.Time `db:"finished_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type AIRecord struct { + RecordID string `db:"record_id"` + TenantCode string `db:"tenant_code"` + UserID string `db:"user_id"` + RecordType string `db:"record_type"` + Title string `db:"title"` + Summary string `db:"summary"` + Status string `db:"status"` + DetailID string `db:"detail_id"` + TaskID string `db:"task_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + DeletedAt *time.Time `db:"deleted_at"` +} + +type AIDocument struct { + RecordID string `db:"record_id"` + UserID string `db:"user_id"` + DocumentType string `db:"document_type"` + Title string `db:"title"` + ContentMarkdown string `db:"content_markdown"` + ContentPlain string `db:"content_plain"` + SectionsJSON string `db:"sections_json"` + SourceExcerpt string `db:"source_excerpt"` + PromptVersion string `db:"prompt_version"` + Model string `db:"model"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type AITableAnalysis struct { + RecordID string `db:"record_id"` + UserID string `db:"user_id"` + FileID string `db:"file_id"` + FileName string `db:"file_name"` + FileType string `db:"file_type"` + FileSize int64 `db:"file_size"` + Topic string `db:"topic"` + MetricsJSON string `db:"metrics_json"` + PreviewJSON string `db:"preview_json"` + AlertsJSON string `db:"alerts_json"` + Interpretation string `db:"interpretation"` + ExportFileID string `db:"export_file_id"` + PromptVersion string `db:"prompt_version"` + Model string `db:"model"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type AIMeetingSummary struct { + SummaryID string `db:"summary_id"` + MeetingID string `db:"meeting_id"` + UserID string `db:"user_id"` + TaskID string `db:"task_id"` + ConclusionsJSON string `db:"conclusions_json"` + DecisionsJSON string `db:"decisions_json"` + ActionItemsJSON string `db:"action_items_json"` + SourceFileIDsJSON string `db:"source_file_ids_json"` + PromptVersion string `db:"prompt_version"` + Model string `db:"model"` + EditedBy string `db:"edited_by"` + EditedAt *time.Time `db:"edited_at"` + Version int `db:"version"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type AIUsageLog struct { + TenantCode string + UserID string + Feature string + TaskID string + Provider string + Model string + ProviderRequestID string + PromptTokens int + CompletionTokens int + TotalTokens int + LatencyMS int + Status string + ErrorReason string + InputHash string + OutputHash string +} + +type AIFileMetadata struct { + MIMEType string + FileSize int64 + FileETag string + Location string +} + +func (s *Store) AICapabilities(ctx context.Context) ([]AICapability, error) { + var items []AICapability + err := s.db.SelectContext(ctx, &items, `SELECT feature,enabled,model,daily_limit,concurrency_limit, + max_input_chars,max_file_size_mb,COALESCE(CAST(config_json AS CHAR), '{}') AS config_json + FROM wb_ai_capability_config WHERE tenant_id=? ORDER BY feature ASC`, s.tenant()) + if err != nil { + return nil, err + } + if len(items) > 0 { + return items, nil + } + err = s.db.SelectContext(ctx, &items, `SELECT feature,enabled,model,daily_limit,concurrency_limit, + max_input_chars,max_file_size_mb,COALESCE(CAST(config_json AS CHAR), '{}') AS config_json + FROM wb_ai_capability_config WHERE tenant_id=0 ORDER BY feature ASC`) + return items, err +} + +func (s *Store) AIFileMetadata(ctx context.Context, fileID string) AIFileMetadata { + meta := s.fileUploadMetadata(ctx, strings.TrimSpace(fileID)) + return AIFileMetadata{ + MIMEType: meta.MIMEType, + FileSize: meta.FileSize, + FileETag: meta.FileETag, + Location: meta.Location, + } +} + +func (s *Store) CreateAITask(ctx context.Context, task AITask, result any) error { + now := time.Now().UTC().Round(time.Millisecond) + resultJSON, err := marshalNullable(result) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `INSERT INTO wb_ai_task + (task_id,tenant_id,tenant_code,user_id,feature,status,progress,source_type,source_ref_id,input_hash, + input_snapshot,prompt_version,model,result_ref_id,result_json,error_reason,error_message,retry_count, + started_at,finished_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,NULL,?,?,?,?,?,?,0,?,?,?,?)`, + task.TaskID, s.tenant(), task.TenantCode, task.UserID, task.Feature, task.Status, task.Progress, + task.SourceType, task.SourceRefID, task.InputHash, task.PromptVersion, task.Model, task.ResultRefID, + resultJSON, task.ErrorReason, task.ErrorMessage, task.StartedAt, task.FinishedAt, now, now) + return err +} + +func (s *Store) UpdateAITaskResult(ctx context.Context, taskID, status string, progress int, resultRefID string, result any, reason, message string) error { + now := time.Now().UTC().Round(time.Millisecond) + resultJSON, err := marshalNullable(result) + if err != nil { + return err + } + res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_task SET status=?,progress=?,result_ref_id=?,result_json=?, + error_reason=?,error_message=?,finished_at=?,updated_at=? WHERE tenant_id=? AND task_id=?`, + status, progress, resultRefID, resultJSON, reason, message, now, now, s.tenant(), taskID) + if err != nil { + return err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) CancelAITask(ctx context.Context, userID int64, taskID string) error { + now := time.Now().UTC().Round(time.Millisecond) + res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_task SET status='canceled',progress=100,finished_at=?,updated_at=? + WHERE tenant_id=? AND user_id=? AND task_id=? AND status IN ('queued','processing')`, + now, now, s.tenant(), formatInt64(userID), taskID) + if err != nil { + return err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) AITask(ctx context.Context, userID int64, taskID string) (*AITask, error) { + var task AITask + err := s.db.GetContext(ctx, &task, `SELECT task_id,tenant_code,user_id,feature,status,progress,source_type,source_ref_id, + input_hash,prompt_version,model,result_ref_id,result_json,error_reason,error_message,retry_count, + started_at,finished_at,created_at,updated_at + FROM wb_ai_task WHERE tenant_id=? AND user_id=? AND task_id=?`, s.tenant(), formatInt64(userID), taskID) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &task, nil +} + +func (s *Store) InsertAIUsageLog(ctx context.Context, log AIUsageLog) error { + now := time.Now().UTC().Round(time.Millisecond) + _, err := s.db.ExecContext(ctx, `INSERT INTO wb_ai_usage_log + (tenant_id,tenant_code,user_id,feature,task_id,provider,model,provider_request_id,prompt_tokens, + completion_tokens,total_tokens,latency_ms,status,error_reason,input_hash,output_hash,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + s.tenant(), log.TenantCode, log.UserID, log.Feature, log.TaskID, log.Provider, log.Model, log.ProviderRequestID, + log.PromptTokens, log.CompletionTokens, log.TotalTokens, log.LatencyMS, log.Status, log.ErrorReason, + log.InputHash, log.OutputHash, now) + return err +} + +func (s *Store) SaveAIDocument(ctx context.Context, tenantCode string, userID int64, record AIRecord, doc AIDocument) error { + now := time.Now().UTC().Round(time.Millisecond) + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_record + (record_id,tenant_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, + record.RecordID, s.tenant(), tenantCode, formatInt64(userID), record.RecordType, record.Title, record.Summary, + record.Status, doc.RecordID, record.TaskID, now, now) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_document + (record_id,tenant_id,user_id,document_type,title,content_markdown,content_plain,sections_json,source_excerpt, + prompt_version,model,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, + doc.RecordID, s.tenant(), formatInt64(userID), doc.DocumentType, doc.Title, doc.ContentMarkdown, doc.ContentPlain, + doc.SectionsJSON, doc.SourceExcerpt, doc.PromptVersion, doc.Model, now, now) + if err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) AIDocuments(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]AIRecord, string, error) { + return s.aiRecords(ctx, userID, "smart_document", queryText, cursor, limit) +} + +func (s *Store) AIDocument(ctx context.Context, userID int64, recordID string) (*AIDocument, error) { + var doc AIDocument + err := s.db.GetContext(ctx, &doc, `SELECT d.record_id,d.user_id,d.document_type,d.title,d.content_markdown, + COALESCE(d.content_plain,'') AS content_plain,COALESCE(CAST(d.sections_json AS CHAR), '[]') AS sections_json, + d.source_excerpt,d.prompt_version,d.model,d.created_at,d.updated_at + FROM wb_ai_document d + JOIN wb_ai_record r ON r.tenant_id=d.tenant_id AND r.record_id=d.record_id + WHERE d.tenant_id=? AND d.user_id=? AND d.record_id=? AND r.deleted_at IS NULL`, + s.tenant(), formatInt64(userID), recordID) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &doc, nil +} + +func (s *Store) SaveAITableAnalysis(ctx context.Context, tenantCode string, userID int64, record AIRecord, analysis AITableAnalysis) error { + now := time.Now().UTC().Round(time.Millisecond) + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_record + (record_id,tenant_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, + record.RecordID, s.tenant(), tenantCode, formatInt64(userID), record.RecordType, record.Title, record.Summary, + record.Status, analysis.RecordID, record.TaskID, now, now) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO wb_ai_table_analysis + (record_id,tenant_id,user_id,file_id,file_name,file_type,file_size,topic,metrics_json,preview_json,alerts_json, + interpretation,export_file_id,prompt_version,model,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + analysis.RecordID, s.tenant(), formatInt64(userID), analysis.FileID, analysis.FileName, analysis.FileType, analysis.FileSize, + analysis.Topic, analysis.MetricsJSON, analysis.PreviewJSON, analysis.AlertsJSON, analysis.Interpretation, analysis.ExportFileID, + analysis.PromptVersion, analysis.Model, now, now) + if err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) AITableAnalyses(ctx context.Context, userID int64, queryText, cursor string, limit int) ([]AIRecord, string, error) { + return s.aiRecords(ctx, userID, "smart_table", queryText, cursor, limit) +} + +func (s *Store) AIRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) { + return s.aiRecords(ctx, userID, recordType, queryText, cursor, limit) +} + +func (s *Store) DeleteAIRecord(ctx context.Context, userID int64, recordID string) error { + now := time.Now().UTC().Round(time.Millisecond) + res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_record SET status='deleted',deleted_at=?,updated_at=? + WHERE tenant_id=? AND user_id=? AND record_id=? AND deleted_at IS NULL`, + now, now, s.tenant(), formatInt64(userID), recordID) + if err != nil { + return err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) AITableAnalysis(ctx context.Context, userID int64, recordID string) (*AITableAnalysis, error) { + var item AITableAnalysis + err := s.db.GetContext(ctx, &item, `SELECT a.record_id,a.user_id,a.file_id,a.file_name,a.file_type,a.file_size,a.topic, + COALESCE(CAST(a.metrics_json AS CHAR), '[]') AS metrics_json, + COALESCE(CAST(a.preview_json AS CHAR), '{}') AS preview_json, + COALESCE(CAST(a.alerts_json AS CHAR), '[]') AS alerts_json, + a.interpretation,a.export_file_id,a.prompt_version,a.model,a.created_at,a.updated_at + FROM wb_ai_table_analysis a + JOIN wb_ai_record r ON r.tenant_id=a.tenant_id AND r.record_id=a.record_id + WHERE a.tenant_id=? AND a.user_id=? AND a.record_id=? AND r.deleted_at IS NULL`, + s.tenant(), formatInt64(userID), recordID) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &item, nil +} + +func (s *Store) UpsertAIMeetingSummary(ctx context.Context, tenantCode string, userID int64, summary AIMeetingSummary) error { + now := time.Now().UTC().Round(time.Millisecond) + _, err := s.db.ExecContext(ctx, `INSERT INTO wb_ai_meeting_summary + (summary_id,tenant_id,tenant_code,meeting_id,user_id,task_id,conclusions_json,decisions_json,action_items_json, + source_file_ids_json,prompt_version,model,version,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1,?,?) + ON DUPLICATE KEY UPDATE task_id=VALUES(task_id),conclusions_json=VALUES(conclusions_json), + decisions_json=VALUES(decisions_json),action_items_json=VALUES(action_items_json), + source_file_ids_json=VALUES(source_file_ids_json),prompt_version=VALUES(prompt_version),model=VALUES(model), + version=version+1,updated_at=VALUES(updated_at)`, + summary.SummaryID, s.tenant(), tenantCode, summary.MeetingID, formatInt64(userID), summary.TaskID, + summary.ConclusionsJSON, summary.DecisionsJSON, summary.ActionItemsJSON, summary.SourceFileIDsJSON, + summary.PromptVersion, summary.Model, now, now) + return err +} + +func (s *Store) AIMeetingSummary(ctx context.Context, userID int64, meetingID string) (*AIMeetingSummary, error) { + var item AIMeetingSummary + err := s.db.GetContext(ctx, &item, `SELECT summary_id,meeting_id,user_id,task_id, + COALESCE(CAST(conclusions_json AS CHAR), '[]') AS conclusions_json, + COALESCE(CAST(decisions_json AS CHAR), '[]') AS decisions_json, + COALESCE(CAST(action_items_json AS CHAR), '[]') AS action_items_json, + COALESCE(CAST(source_file_ids_json AS CHAR), '[]') AS source_file_ids_json, + prompt_version,model,edited_by,edited_at,version,created_at,updated_at + FROM wb_ai_meeting_summary WHERE tenant_id=? AND meeting_id=? LIMIT 1`, s.tenant(), meetingID) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &item, nil +} + +func (s *Store) UpdateAIMeetingSummary(ctx context.Context, userID int64, meetingID, summaryID, conclusionsJSON, decisionsJSON, actionItemsJSON string) (*AIMeetingSummary, error) { + meeting, err := s.Meeting(ctx, userID, meetingID) + if err != nil { + return nil, err + } + if meeting.OrganizerID != userID && meeting.RecorderID != userID { + return nil, ErrForbidden + } + now := time.Now().UTC().Round(time.Millisecond) + res, err := s.db.ExecContext(ctx, `UPDATE wb_ai_meeting_summary SET conclusions_json=?,decisions_json=?,action_items_json=?, + edited_by=?,edited_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND meeting_id=? AND summary_id=?`, + conclusionsJSON, decisionsJSON, actionItemsJSON, formatInt64(userID), now, now, s.tenant(), meetingID, summaryID) + if err != nil { + return nil, err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return nil, ErrNotFound + } + return s.AIMeetingSummary(ctx, userID, meetingID) +} + +func (s *Store) aiRecords(ctx context.Context, userID int64, recordType, queryText, cursor string, limit int) ([]AIRecord, string, error) { + where := `tenant_id=? AND user_id=? AND deleted_at IS NULL` + args := []any{s.tenant(), formatInt64(userID)} + if recordType != "" { + where += ` AND record_type=?` + args = append(args, recordType) + } + if queryText != "" { + where += ` AND (title LIKE ? OR summary LIKE ?)` + like := "%" + queryText + "%" + args = append(args, like, like) + } + if cursor != "" { + where += ` AND record_id limit { + next = items[limit-1].RecordID + items = items[:limit] + } + return items, next, nil +} + +func marshalNullable(value any) (any, error) { + if value == nil { + return nil, nil + } + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + return string(data), nil +} + +func formatInt64(value int64) string { + return strconv.FormatInt(value, 10) +} diff --git a/internal/store/mysql.go b/internal/store/mysql.go index 1048e30..ad7eb85 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -135,6 +135,9 @@ func (s *Store) WorkbenchModules(ctx context.Context, userID int64) ([]Workbench err := s.db.SelectContext(ctx, &modules, `SELECT code,name,IFNULL(icon_file_id,'') AS icon_file_id,route,sort_order,0 AS unread_count,enabled,permission FROM im_workbench_apps WHERE tenant_id=? AND enabled=1 ORDER BY sort_order ASC,id ASC`, s.tenant()) if err != nil { + if isMissingTable(err) { + return []WorkbenchModule{}, 1, nil + } return nil, 0, err } for i := range modules { @@ -436,6 +439,9 @@ func (s *Store) Meeting(ctx context.Context, userID int64, meetingID string) (*M if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } + if isMissingTable(err) { + return nil, ErrNotFound + } return nil, err } meeting.Participants, _ = s.meetingParticipants(ctx, meeting.MeetingID) diff --git a/internal/tableparse/tableparse.go b/internal/tableparse/tableparse.go new file mode 100644 index 0000000..b4827a7 --- /dev/null +++ b/internal/tableparse/tableparse.go @@ -0,0 +1,254 @@ +package tableparse + +import ( + "encoding/csv" + "fmt" + "io" + "math" + "os" + "strconv" + "strings" + + "github.com/xuri/excelize/v2" +) + +const ( + maxSampleRows = 20 + maxRows = 5000 +) + +type Profile struct { + Columns []ColumnStat `json:"columns"` + SampleRows [][]string `json:"sample_rows"` + RowCount int `json:"row_count"` + Stats map[string]NumericStat `json:"stats,omitempty"` + Outliers []Outlier `json:"outliers,omitempty"` + Parsed bool `json:"parsed"` + Message string `json:"message,omitempty"` +} + +type ColumnStat struct { + Name string `json:"name"` + Type string `json:"type"` + Empty int `json:"empty"` +} + +type NumericStat struct { + Count int `json:"count"` + Sum float64 `json:"sum"` + Avg float64 `json:"avg"` + Min float64 `json:"min"` + Max float64 `json:"max"` +} + +type Outlier struct { + Row int `json:"row"` + Column string `json:"column"` + Description string `json:"description"` +} + +func Parse(path, fileType string) (Profile, error) { + switch strings.ToLower(strings.TrimSpace(fileType)) { + case "csv": + return parseCSV(path) + case "xlsx": + return parseXLSX(path) + case "xls": + return Profile{Parsed: false, Message: "legacy xls parsing is not supported yet"}, nil + default: + return Profile{}, fmt.Errorf("unsupported table file type: %s", fileType) + } +} + +func parseCSV(path string) (Profile, error) { + f, err := os.Open(path) + if err != nil { + return Profile{}, err + } + defer f.Close() + reader := csv.NewReader(f) + reader.FieldsPerRecord = -1 + rows := make([][]string, 0, maxRows) + for len(rows) < maxRows { + row, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return Profile{}, err + } + if emptyRow(row) { + continue + } + rows = append(rows, trimRow(row)) + } + return profileRows(rows), nil +} + +func parseXLSX(path string) (Profile, error) { + f, err := excelize.OpenFile(path) + if err != nil { + return Profile{}, err + } + defer f.Close() + for _, sheet := range f.GetSheetList() { + rows, err := f.GetRows(sheet) + if err != nil { + continue + } + cleaned := make([][]string, 0, min(len(rows), maxRows)) + for _, row := range rows { + if emptyRow(row) { + continue + } + cleaned = append(cleaned, trimRow(row)) + if len(cleaned) >= maxRows { + break + } + } + if len(cleaned) > 0 { + return profileRows(cleaned), nil + } + } + return Profile{Parsed: false, Message: "no non-empty sheet found"}, nil +} + +func profileRows(rows [][]string) Profile { + if len(rows) == 0 { + return Profile{Parsed: false, Message: "empty table"} + } + headers := rows[0] + if len(headers) == 0 { + return Profile{Parsed: false, Message: "empty header"} + } + dataRows := rows[1:] + columnCount := len(headers) + columns := make([]ColumnStat, columnCount) + statsWork := make([]numericWork, columnCount) + for i, name := range headers { + name = strings.TrimSpace(name) + if name == "" { + name = fmt.Sprintf("列%d", i+1) + } + columns[i] = ColumnStat{Name: name, Type: "text"} + } + sample := make([][]string, 0, min(len(dataRows), maxSampleRows)) + for rowIndex, row := range dataRows { + normalized := normalizeWidth(row, columnCount) + if len(sample) < maxSampleRows { + sample = append(sample, normalized) + } + for col := 0; col < columnCount; col++ { + value := strings.TrimSpace(normalized[col]) + if value == "" { + columns[col].Empty++ + continue + } + if parsed, ok := parseNumber(value); ok { + statsWork[col].add(parsed, rowIndex+2) + } + } + } + stats := map[string]NumericStat{} + outliers := []Outlier{} + for i := range columns { + work := statsWork[i] + nonEmpty := len(dataRows) - columns[i].Empty + if work.Count > 0 && work.Count*2 >= max(1, nonEmpty) { + columns[i].Type = "number" + stat := work.stat() + stats[columns[i].Name] = stat + if stat.Count >= 3 { + for _, point := range work.Points { + if point.Value == stat.Max && stat.Avg > 0 && point.Value > stat.Avg*2 { + outliers = append(outliers, Outlier{Row: point.Row, Column: columns[i].Name, Description: "数值明显高于平均值"}) + break + } + } + } + } + } + return Profile{Columns: columns, SampleRows: sample, RowCount: len(dataRows), Stats: stats, Outliers: outliers, Parsed: true} +} + +type numericWork struct { + Count int + Sum float64 + Min float64 + Max float64 + Points []numericPoint +} + +type numericPoint struct { + Row int + Value float64 +} + +func (w *numericWork) add(value float64, row int) { + if w.Count == 0 { + w.Min = value + w.Max = value + } else { + w.Min = math.Min(w.Min, value) + w.Max = math.Max(w.Max, value) + } + w.Count++ + w.Sum += value + if len(w.Points) < 200 { + w.Points = append(w.Points, numericPoint{Row: row, Value: value}) + } +} + +func (w numericWork) stat() NumericStat { + avg := 0.0 + if w.Count > 0 { + avg = w.Sum / float64(w.Count) + } + return NumericStat{Count: w.Count, Sum: w.Sum, Avg: avg, Min: w.Min, Max: w.Max} +} + +func parseNumber(value string) (float64, bool) { + value = strings.TrimSpace(strings.ReplaceAll(value, ",", "")) + value = strings.TrimSuffix(value, "%") + parsed, err := strconv.ParseFloat(value, 64) + return parsed, err == nil +} + +func normalizeWidth(row []string, width int) []string { + out := make([]string, width) + for i := 0; i < width && i < len(row); i++ { + out[i] = strings.TrimSpace(row[i]) + } + return out +} + +func trimRow(row []string) []string { + out := make([]string, len(row)) + for i := range row { + out[i] = strings.TrimSpace(row[i]) + } + return out +} + +func emptyRow(row []string) bool { + for _, value := range row { + if strings.TrimSpace(value) != "" { + return false + } + } + return true +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/tableparse/tableparse_test.go b/internal/tableparse/tableparse_test.go new file mode 100644 index 0000000..a529867 --- /dev/null +++ b/internal/tableparse/tableparse_test.go @@ -0,0 +1,49 @@ +package tableparse + +import ( + "os" + "path/filepath" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestParseCSVBuildsProfile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sales.csv") + if err := os.WriteFile(path, []byte("date,sales,orders\n8/1,100,2\n8/2,200,4\n"), 0o600); err != nil { + t.Fatal(err) + } + profile, err := Parse(path, "csv") + if err != nil { + t.Fatal(err) + } + if !profile.Parsed || profile.RowCount != 2 { + t.Fatalf("profile parsed=%v rowCount=%d, want parsed=true rowCount=2", profile.Parsed, profile.RowCount) + } + if got := profile.Stats["sales"].Sum; got != 300 { + t.Fatalf("sales sum = %v, want 300", got) + } +} + +func TestParseXLSXBuildsProfile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sales.xlsx") + f := excelize.NewFile() + if err := f.SetSheetRow("Sheet1", "A1", &[]any{"date", "sales"}); err != nil { + t.Fatal(err) + } + if err := f.SetSheetRow("Sheet1", "A2", &[]any{"8/1", 120}); err != nil { + t.Fatal(err) + } + if err := f.SaveAs(path); err != nil { + t.Fatal(err) + } + profile, err := Parse(path, "xlsx") + if err != nil { + t.Fatal(err) + } + if !profile.Parsed || profile.Columns[1].Type != "number" { + t.Fatalf("profile parsed=%v sales type=%q, want parsed=true number", profile.Parsed, profile.Columns[1].Type) + } +}