feat: add workbench AI integration
This commit is contained in:
@@ -24,7 +24,7 @@ IM_WORKBENCH_INTERNAL_SECRET=local-workbench-secret \
|
|||||||
go run ./cmd/workbench
|
go run ./cmd/workbench
|
||||||
```
|
```
|
||||||
|
|
||||||
默认监听 `:6061`,默认连接本地 `meeting_im` 库和本地 Redis。
|
默认监听 `:6061`,默认连接本地 `workbench` 库和本地 Redis。
|
||||||
|
|
||||||
开发联调可在 `configs/workbench.local.json` 打开 `allow_dev_header_auth`,请求头带:
|
开发联调可在 `configs/workbench.local.json` 打开 `allow_dev_header_auth`,请求头带:
|
||||||
|
|
||||||
@@ -92,6 +92,28 @@ go run ./cmd/workbench
|
|||||||
|
|
||||||
测试环境使用 `configs/workbench.test.json`,Kafka 为明文 PLAINTEXT 直连,不配置 SASL/TLS。
|
测试环境使用 `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
|
```json
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"listen": ":6061",
|
"listen": ":6061",
|
||||||
"mysql": {
|
"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_open_conns": 15,
|
||||||
"max_idle_conns": 5
|
"max_idle_conns": 5
|
||||||
},
|
},
|
||||||
@@ -40,5 +40,18 @@
|
|||||||
"uid_key"
|
"uid_key"
|
||||||
],
|
],
|
||||||
"timeout_ms": 3000
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
@@ -23,10 +23,17 @@ require (
|
|||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.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/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.1.2 // indirect
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // 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
|
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/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.8 // indirect
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
|
||||||
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
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 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno=
|
||||||
github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
|
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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
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 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
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 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
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=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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.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 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
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-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/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=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
+3
-1
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"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/auth"
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
"git.118im.com/im_team/im_workbench/internal/events"
|
"git.118im.com/im_team/im_workbench/internal/events"
|
||||||
@@ -57,11 +58,12 @@ func New(cfg *config.Config) (*App, error) {
|
|||||||
_ = st.Close()
|
_ = st.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
aiService := ai.NewService(ai.NewClient(cfg.AI))
|
||||||
return &App{
|
return &App{
|
||||||
store: st,
|
store: st,
|
||||||
redis: redisClient,
|
redis: redisClient,
|
||||||
events: publisher,
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Config struct {
|
|||||||
Kafka KafkaConfig `json:"kafka"`
|
Kafka KafkaConfig `json:"kafka"`
|
||||||
Auth AuthConfig `json:"auth"`
|
Auth AuthConfig `json:"auth"`
|
||||||
Manager ManagerConfig `json:"manager"`
|
Manager ManagerConfig `json:"manager"`
|
||||||
|
AI AIConfig `json:"ai"`
|
||||||
ReadTimeout time.Duration `json:"-"`
|
ReadTimeout time.Duration `json:"-"`
|
||||||
WriteTimeout time.Duration `json:"-"`
|
WriteTimeout time.Duration `json:"-"`
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,22 @@ type ManagerConfig struct {
|
|||||||
TimeoutMS int `json:"timeout_ms"`
|
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) {
|
func Load() (*Config, error) {
|
||||||
cfg := defaultConfig()
|
cfg := defaultConfig()
|
||||||
if path := strings.TrimSpace(os.Getenv("IM_WORKBENCH_CONFIG")); path != "" {
|
if path := strings.TrimSpace(os.Getenv("IM_WORKBENCH_CONFIG")); path != "" {
|
||||||
@@ -92,7 +109,7 @@ func defaultConfig() *Config {
|
|||||||
return &Config{
|
return &Config{
|
||||||
Listen: ":6061",
|
Listen: ":6061",
|
||||||
MySQL: MySQLConfig{
|
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,
|
MaxOpenConns: 15,
|
||||||
MaxIdleConns: 5,
|
MaxIdleConns: 5,
|
||||||
},
|
},
|
||||||
@@ -116,6 +133,18 @@ func defaultConfig() *Config {
|
|||||||
UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"},
|
UIDKeyKeys: []string{"uid_encryption_key", "UID_ENCRYPTION_KEY", "uid_key"},
|
||||||
TimeoutMS: 3000,
|
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,
|
ReadTimeout: 8 * time.Second,
|
||||||
WriteTimeout: 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.ServiceName, "IM_WORKBENCH_MANAGER_SERVICE_NAME")
|
||||||
setString(&cfg.Manager.Secret, "IM_WORKBENCH_MANAGER_SECRET")
|
setString(&cfg.Manager.Secret, "IM_WORKBENCH_MANAGER_SECRET")
|
||||||
setString(&cfg.Manager.UIDKeyGroup, "IM_WORKBENCH_MANAGER_UID_KEY_GROUP")
|
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) == "" {
|
if strings.TrimSpace(cfg.Auth.InternalSecret) == "" {
|
||||||
setString(&cfg.Auth.InternalSecret, "IM_INTERNAL_API_SECRET")
|
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)
|
return fmt.Errorf("mysql dsn is invalid: %w", err)
|
||||||
}
|
}
|
||||||
if strings.EqualFold(strings.TrimSpace(parsedDSN.DBName), "tinode") {
|
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 {
|
if strings.TrimSpace(c.Redis.Addr) == "" && !c.Auth.SkipSessionInDev {
|
||||||
return errors.New("redis addr is required")
|
return errors.New("redis addr is required")
|
||||||
@@ -211,6 +249,27 @@ func (c *Config) validate() error {
|
|||||||
if !strings.Contains(c.Auth.SessionKeyPattern, "%s") {
|
if !strings.Contains(c.Auth.SessionKeyPattern, "%s") {
|
||||||
return fmt.Errorf("session key pattern must include %%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
|
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 {
|
func splitCSV(value string) []string {
|
||||||
parts := strings.Split(value, ",")
|
parts := strings.Split(value, ",")
|
||||||
items := make([]string, 0, len(parts))
|
items := make([]string, 0, len(parts))
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ func TestValidateRejectsDeprecatedTinodeDatabase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultConfigUsesMeetingIMDatabase(t *testing.T) {
|
func TestDefaultConfigUsesWorkbenchDatabase(t *testing.T) {
|
||||||
cfg := defaultConfig()
|
cfg := defaultConfig()
|
||||||
if err := cfg.validate(); err != nil {
|
if err := cfg.validate(); err != nil {
|
||||||
t.Fatalf("default config validation failed: %v", err)
|
t.Fatalf("default config validation failed: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(cfg.MySQL.DSN, "/meeting_im?") {
|
if !strings.Contains(cfg.MySQL.DSN, "/workbench?") {
|
||||||
t.Fatalf("default mysql dsn = %q, want meeting_im database", cfg.MySQL.DSN)
|
t.Fatalf("default mysql dsn = %q, want workbench database", cfg.MySQL.DSN)
|
||||||
}
|
}
|
||||||
if cfg.MySQL.MaxOpenConns != 15 || cfg.MySQL.MaxIdleConns != 5 {
|
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)
|
t.Fatalf("default mysql pool = %d/%d, want 15/5", cfg.MySQL.MaxOpenConns, cfg.MySQL.MaxIdleConns)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+18
-2
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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/auth"
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/im_workbench/internal/config"
|
||||||
"git.118im.com/im_team/im_workbench/internal/events"
|
"git.118im.com/im_team/im_workbench/internal/events"
|
||||||
@@ -23,6 +24,7 @@ type API struct {
|
|||||||
auth *auth.Authenticator
|
auth *auth.Authenticator
|
||||||
events events.Publisher
|
events events.Publisher
|
||||||
authCfg config.AuthConfig
|
authCfg config.AuthConfig
|
||||||
|
ai *ai.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
const workbenchEventPublishTimeout = 15 * time.Second
|
const workbenchEventPublishTimeout = 15 * time.Second
|
||||||
@@ -32,15 +34,27 @@ const (
|
|||||||
maxMailboxContentLength = 5000
|
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 {
|
if publisher == nil {
|
||||||
publisher = events.NoopPublisher{}
|
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) {
|
func (a *API) Register(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/v1/workbench", a.withAuth(a.workbench))
|
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.organizationDepartments))
|
||||||
mux.HandleFunc("/v1/workbench/organization/departments/", a.withAuth(a.organizationDepartmentResource))
|
mux.HandleFunc("/v1/workbench/organization/departments/", a.withAuth(a.organizationDepartmentResource))
|
||||||
mux.HandleFunc("/v1/workbench/organization/members", a.withAuth(a.organizationMembers))
|
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))
|
writeSuccess(w, r, meetingPayload(*meeting, p.UserID))
|
||||||
case tail == "notes" && r.Method == http.MethodGet:
|
case tail == "notes" && r.Method == http.MethodGet:
|
||||||
a.meetingNotesDetail(w, r, p, st, meetingID)
|
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:
|
case tail == "notes/files" && r.Method == http.MethodPost:
|
||||||
a.meetingNoteFiles(w, r, p, st, meetingID)
|
a.meetingNoteFiles(w, r, p, st, meetingID)
|
||||||
case strings.HasPrefix(tail, "notes/files/"):
|
case strings.HasPrefix(tail, "notes/files/"):
|
||||||
|
|||||||
@@ -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<?`
|
||||||
|
args = append(args, cursor)
|
||||||
|
}
|
||||||
|
args = append(args, limit+1)
|
||||||
|
var items []AIRecord
|
||||||
|
err := s.db.SelectContext(ctx, &items, `SELECT record_id,tenant_code,user_id,record_type,title,summary,status,detail_id,task_id,
|
||||||
|
created_at,updated_at,deleted_at FROM wb_ai_record WHERE `+where+` ORDER BY created_at DESC,record_id DESC LIMIT ?`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if len(items) > 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)
|
||||||
|
}
|
||||||
@@ -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
|
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())
|
FROM im_workbench_apps WHERE tenant_id=? AND enabled=1 ORDER BY sort_order ASC,id ASC`, s.tenant())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isMissingTable(err) {
|
||||||
|
return []WorkbenchModule{}, 1, nil
|
||||||
|
}
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
for i := range modules {
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
if isMissingTable(err) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
meeting.Participants, _ = s.meetingParticipants(ctx, meeting.MeetingID)
|
meeting.Participants, _ = s.meetingParticipants(ctx, meeting.MeetingID)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user