chore: 初始化 eightbf-analysis-go(对齐 Java 会商作业服务)
Go 实现 /api/v1/analysis 契约、Demo/Live Worker、DeerFlow SSE;含设计文档与冒烟脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
0c45b86d67
29 files changed
+3789
No files matched your search
+10
@@ -0,0 +1,10 @@
|
||||
/bin/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
/data/
|
||||
*.log
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
@@ -0,0 +1,74 @@
|
||||
# eightbf-analysis-go
|
||||
|
||||
8BF「多岗位会商」后台服务的 **Go 实现**,行为对齐 Java 仓 `eightbf-analysis` 与
|
||||
[docs/服务端开发设计要求.md](./docs/服务端开发设计要求.md)。
|
||||
|
||||
- 同一套 HTTP:`/api/v1/analysis/...`
|
||||
- 同一张表:`eba_analysis_job`(`schema.sql`)
|
||||
- 同一套环境变量:`EBA_DB_*` / `EBA_DF_*`(Go 另支持 `EBA_DB_DSN`)
|
||||
- 前端可经 Nginx `/analysis-api` 指向本进程(默认仍 `:18097`,勿与 Java 实例同时占端口)
|
||||
|
||||
## 与 Java 的关系
|
||||
|
||||
| | Java | Go(本仓) |
|
||||
|--|------|------------|
|
||||
| 定位 | 现网/本机主用 | 同构移植,便于无 JVM 环境部署 |
|
||||
| API | 权威契约 | **兼容**同一契约 |
|
||||
| 表结构 | `schema.sql` | 共用同一 schema |
|
||||
|
||||
两进程不要同时写同一库抢同一 `queued`(尚无领取互斥)。切换时停掉一侧再起另一侧。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 需要 Go 1.21+、MySQL(库 eightbf_analysis)
|
||||
export EBA_DB_USER=eba
|
||||
export EBA_DB_PASSWORD=eba_local_dev
|
||||
# 或直接:
|
||||
# export EBA_DB_DSN='eba:eba_local_dev@tcp(127.0.0.1:3306)/eightbf_analysis?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4'
|
||||
|
||||
export EBA_DF_BASE_URL=http://127.0.0.1:8001
|
||||
export ANALYSIS_MODE=live # demo 则假进度
|
||||
# export ANALYSIS_AUTH_ENABLED=true
|
||||
|
||||
cd /project/eightbf-analysis-go
|
||||
go run ./cmd/server
|
||||
# 默认 :18097
|
||||
```
|
||||
|
||||
构建:
|
||||
|
||||
```bash
|
||||
go build -o bin/eightbf-analysis-go ./cmd/server
|
||||
./bin/eightbf-analysis-go
|
||||
```
|
||||
|
||||
## 接口(与 Java 一致)
|
||||
|
||||
- `GET /api/v1/analysis/health`
|
||||
- `POST /api/v1/analysis/jobs`
|
||||
- `GET /api/v1/analysis/jobs/{id}`
|
||||
- `GET /api/v1/analysis/jobs?taskId=&latest=`
|
||||
- `POST /api/v1/analysis/jobs/{id}/cancel|resume|plan|inject`
|
||||
- `GET /api/v1/analysis/jobs/{id}/result`
|
||||
|
||||
身份头:`X-Account-Id`、`X-Analysis-Role`。
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
cmd/server 入口
|
||||
internal/api HTTP
|
||||
internal/service 编排 / 鉴权投影 / DemoWorker
|
||||
internal/worker LiveWorker / LiveChainRunner / 提示词
|
||||
internal/deerflow 登录、SSE、模型校验
|
||||
internal/repo MySQL
|
||||
internal/domain 作业实体与状态常量
|
||||
internal/config 环境变量
|
||||
docs/ 设计要求(自 Java 同步)+ env.sample
|
||||
```
|
||||
|
||||
## 日志
|
||||
|
||||
遵循设计要求 §11:业务日志带 `jobId=`;禁止打印 token/密码/完整 dialogues。
|
||||
当前写 stdout,正式环境用 systemd/Docker 收集或挂滚动文件。
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/api"
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/config"
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/deerflow"
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/service"
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/worker"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
log.Printf("eightbf-analysis-go starting port=%d mode=%s auth=%v demo=%v",
|
||||
cfg.Port, cfg.AnalysisMode, cfg.AuthEnabled, cfg.DemoEnabled)
|
||||
|
||||
db, err := sql.Open("mysql", cfg.DBDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(20)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("ping db: %v(请检查 EBA_DB_DSN / EBA_DB_*)", err)
|
||||
}
|
||||
|
||||
jobRepo := repo.NewJobRepo(db)
|
||||
if schema, err := os.ReadFile("schema.sql"); err != nil {
|
||||
log.Printf("未找到 schema.sql,跳过自动建表: %v", err)
|
||||
} else {
|
||||
jobRepo.ApplySchema(string(schema))
|
||||
}
|
||||
|
||||
access := &service.AccessService{AuthEnabled: cfg.AuthEnabled}
|
||||
jobs := &service.JobService{Repo: jobRepo, Access: access}
|
||||
h := &api.Handler{Jobs: jobs}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.Routes(mux)
|
||||
|
||||
if cfg.AnalysisMode == "live" && !cfg.DemoEnabled {
|
||||
auth := deerflow.NewAuth(cfg)
|
||||
df := deerflow.NewClient(cfg, auth)
|
||||
runner := &worker.LiveChainRunner{DF: df, Repo: jobRepo, Coord: cfg.DeerFlowCoordinator}
|
||||
lw := &worker.LiveWorker{
|
||||
Repo: jobRepo,
|
||||
Runner: runner,
|
||||
PollEvery: time.Duration(cfg.LivePollMS) * time.Millisecond,
|
||||
}
|
||||
lw.Start()
|
||||
log.Printf("LiveWorker 已启动 poll=%dms", cfg.LivePollMS)
|
||||
} else if cfg.DemoEnabled || cfg.AnalysisMode == "demo" {
|
||||
dw := &service.DemoWorker{
|
||||
Repo: jobRepo,
|
||||
Every: time.Duration(cfg.DemoTickMS) * time.Millisecond,
|
||||
}
|
||||
dw.Start()
|
||||
log.Printf("DemoWorker 已启动 tick=%dms", cfg.DemoTickMS)
|
||||
} else {
|
||||
log.Printf("警告:未启动任何 Worker(mode=%s demo=%v)", cfg.AnalysisMode, cfg.DemoEnabled)
|
||||
}
|
||||
|
||||
addr := ":" + strconv.Itoa(cfg.Port)
|
||||
log.Printf("监听 %s (健康检查 GET /api/v1/analysis/health)", addr)
|
||||
if err := http.ListenAndServe(addr, api.WithCORS(mux)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# eightbf-analysis / eightbf-analysis-go 内网环境变量样例
|
||||
# 勿把真实密码提交进 Git。
|
||||
|
||||
# MySQL(正式必填)
|
||||
# Java 可用 JDBC;Go 优先 EBA_DB_DSN,也能解析 EBA_DB_URL(jdbc)
|
||||
EBA_DB_URL=jdbc:mysql://<mysql-host>:3306/eightbf_analysis?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
EBA_DB_USER=eba
|
||||
EBA_DB_PASSWORD=
|
||||
# Go 推荐:
|
||||
# EBA_DB_DSN=eba:xxx@tcp(mysql-host:3306)/eightbf_analysis?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true
|
||||
|
||||
# DeerFlow Gateway(ANALYSIS_MODE=live 时必填)
|
||||
EBA_DF_BASE_URL=http://<deerflow-gateway>:8001
|
||||
EBA_DF_USER=admin
|
||||
EBA_DF_PASSWORD=
|
||||
|
||||
# 可选:改端口
|
||||
# SERVER_PORT=18097
|
||||
|
||||
# Go 运行模式
|
||||
# ANALYSIS_MODE=live
|
||||
# ANALYSIS_AUTH_ENABLED=false
|
||||
# EBA_WORKER_DEMO_ENABLED=false
|
||||
# 假进度:ANALYSIS_MODE=demo
|
||||
@@ -0,0 +1,28 @@
|
||||
# Go 版与 Java 版对齐说明
|
||||
|
||||
设计权威:[服务端开发设计要求.md](./服务端开发设计要求.md)(与 Java 仓同步)。
|
||||
|
||||
## 已对齐
|
||||
|
||||
- API 路径、状态码、`ApiResponse`、身份头
|
||||
- 作业状态机、一开一场、cancel_flag、plan 检查点
|
||||
- 人工干预 / resume / inject / patch plan(关干预自动放行)
|
||||
- 岗位投影(position_owner 裁气泡)
|
||||
- Live:模型校验 → init → 总控派活 → 席位 → 可选卡点 → 收口;失败不当成功
|
||||
- Demo 假进度 Worker
|
||||
- 环境变量 `EBA_*`;Go 额外 `EBA_DB_DSN`、`ANALYSIS_MODE`、`ANALYSIS_AUTH_ENABLED`
|
||||
|
||||
## 已知差异 / 注意
|
||||
|
||||
1. **进程互斥**:勿与 Java 实例同时抢同一 MySQL 的 `queued`(尚未做多实例领取锁)。
|
||||
2. **schema 启动**:Go 启动读工作目录 `schema.sql`;部署时保证 cwd 或改绝对路径。
|
||||
3. **SSE 细节**:DeerFlow 帧格式若有变,优先对照 Java `DeerFlowClient` 再改本仓 `internal/deerflow`。
|
||||
4. **日志**:先 stdout;滚动文件按设计要求 §11 后续加 lumberjack 即可。
|
||||
|
||||
## 切换建议
|
||||
|
||||
1. 停 Java 进程
|
||||
2. 确认同一 `EBA_DB_*` / DeerFlow
|
||||
3. 起 Go:`go run ./cmd/server` 或 systemd
|
||||
4. Nginx `/analysis-api` 仍指 `:18097`
|
||||
5. 健康检查 + 开一场研讨验收
|
||||
@@ -0,0 +1,379 @@
|
||||
# eightbf-analysis 服务端开发设计要求
|
||||
|
||||
> 适用范围:本仓库(8BF「多岗位会商」后台作业服务,Spring Boot 2.0.9 + JDK8)。
|
||||
> 目标:关浏览器后会商仍能跑完;状态以服务端为准;产品前端只经 `/analysis-api` 访问本服务。
|
||||
> 配套:根 [README.md](../README.md)、[内网迁移.md](./内网迁移.md)、源码包注释(`package-info` / 各层类注释)。
|
||||
|
||||
本文是**开发约束与设计验收标准**,不是空泛愿景。改功能前先对照;评审以本文为准。
|
||||
|
||||
---
|
||||
|
||||
## 1. 定位与边界
|
||||
|
||||
### 1.1 本服务负责
|
||||
|
||||
| 职责 | 说明 |
|
||||
|------|------|
|
||||
| 会商作业生命周期 | 创建 / 查询 / 取消 / 续跑 / 热更新 / 注入 / 结果 |
|
||||
| 服务端执行 | Demo 假进度或 Live 真模型链条;关页不断 |
|
||||
| 状态与流式内容落库 | `status`、`plan`、`dialogues`、`result` 等以 MySQL 为准 |
|
||||
| 对接 DeerFlow | 登录、建线程、消费 SSE;失败策略由本服务决定 |
|
||||
| 岗位可见性投影 | 按账号裁剪对话后再返回前端 |
|
||||
|
||||
### 1.2 本服务不负责
|
||||
|
||||
- 产品登录 / SSO / 账号体系本体(只认请求头里的账号与角色)
|
||||
- 岗位会商「DeerFlow joinStream」那条链路(岗位侧保持原样,不经本服务挂起)
|
||||
- 浏览器直连 DeerFlow `roundtable-jobs` 跑 8BF(**禁止**作为正式路径)
|
||||
- Fusion 其它 API(`/zl_consumer`、`/deerflow-api` 等)
|
||||
|
||||
### 1.3 硬原则(不可破)
|
||||
|
||||
1. **状态权威在服务端**:前端轮询展示;刷新必须带草稿里的 `jobId`,禁止用「task 下最新一场」冒充重连。
|
||||
2. **一开一场**:每次「开始研讨」新建作业;同一 `taskId` 可并存多场;不自动取消旧场。
|
||||
3. **浏览器可走,作业不能丢**:取消只靠 `cancel_flag` + Worker 协作停;不要假设 SSE 连着用户标签页。
|
||||
4. **失败不当成功**:模型不可用、席位空输出、流无终帧、自动换模 → 整场 `error`;禁止刷完链条再标 `done` 解锁前端下一阶段。
|
||||
5. **热更新以库为准**:人工干预 / 模型 / inject 写在库里;Runner 每席与 flush 前从库合并,禁止内存态盖掉用户 PATCH。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
```text
|
||||
浏览器 → Nginx /analysis-api → eightbf-analysis (:18097)
|
||||
├─ web(HTTP + DTO)
|
||||
├─ service(编排 / 鉴权投影 / 落库 / DemoWorker)
|
||||
├─ worker(LiveWorker 调度 + LiveChainRunner 执行)
|
||||
├─ deerflow(Auth / Client / SSE)
|
||||
└─ MySQL eba_analysis_job
|
||||
↑
|
||||
DeerFlow Gateway(仅服务端访问)
|
||||
```
|
||||
|
||||
### 2.1 分层要求
|
||||
|
||||
| 包 | 允许 | 禁止 |
|
||||
|----|------|------|
|
||||
| `web` | 参数校验、HTTP 状态码、组 Caller、调 service | 直接 JDBC;直接调 DeerFlow;写业务状态机细节 |
|
||||
| `service` | 作业编排、权限、仓储、Demo 推进 | 拼 DeerFlow SSE;复制一套状态机与 Runner 打架 |
|
||||
| `worker` | 真模型调度与链条;读写作业检查点 | 暴露新 HTTP;绕过 Repository 改库 |
|
||||
| `deerflow` | HTTP/SSE、token、模型校验、流式折算 | 知道「作业业务规则」以外的前端约定;改作业 status |
|
||||
| `domain` | 实体、状态常量 | Spring 注解、IO |
|
||||
|
||||
依赖方向只能向内:`web → service → domain`;`worker → service/domain + deerflow`。禁止 `deerflow` 依赖 `web`。
|
||||
|
||||
### 2.2 运行模式
|
||||
|
||||
| 配置 | 行为 |
|
||||
|------|------|
|
||||
| `analysis.mode=live` + `eba.worker.demo-enabled=false` | **正式/联调默认**:LiveWorker 领 `queued`,LiveChainRunner 调 DeerFlow |
|
||||
| `analysis.mode=demo` + `eba.worker.demo-enabled=true` | 仅演示假发言;不得当内网验收真模型 |
|
||||
|
||||
两模式互斥清晰:禁止「一边假进度一边又去打 DeerFlow」。
|
||||
|
||||
---
|
||||
|
||||
## 3. 领域与状态机
|
||||
|
||||
### 3.1 作业(AnalysisJob)
|
||||
|
||||
一场作业 = 服务端上的一场研讨录像带。关键袋子:
|
||||
|
||||
| 字段 | 用途 | 开发约束 |
|
||||
|------|------|----------|
|
||||
| `id` | 主键;草稿必须存 | 新建生成;禁止按 task 覆盖 |
|
||||
| `status` / `phase` | 生命周期 / 细阶段 | 状态字符串只用 `JobStatus` 常量 |
|
||||
| `plan` | 检查点与开关 | 至少:`seatIndex`、`manualIntervention`、`pendingInject`、`awaitReason`、`seedMessage` |
|
||||
| `dialogues` | 气泡流式展示 | 增量追加;终帧勿无脑整段覆盖丢掉 `<think>` |
|
||||
| `threadIds` | DeerFlow 线程 | 有则续跑跳过 init |
|
||||
| `access` | 岗位账号限制 | 结构与前端约定一致 |
|
||||
| `progress` / `result` | 进度文案 / 终态汇总 | `error` 时清掉误导性 summary / 共识 |
|
||||
|
||||
### 3.2 状态流转
|
||||
|
||||
```text
|
||||
queued → running → awaiting_input ⇄ queued(resume)
|
||||
→ done
|
||||
→ error
|
||||
→ cancelled
|
||||
```
|
||||
|
||||
| 状态 | 含义 | 允许的写操作 |
|
||||
|------|------|----------------|
|
||||
| `queued` | 等 Worker 领取 | cancel;inject;patch plan |
|
||||
| `running` | 执行中 | cancel;inject;patch plan |
|
||||
| `awaiting_input` | 澄清或人工卡点 | resume;cancel;inject;patch plan(关干预可自动放行) |
|
||||
| `done` / `error` / `cancelled` | 终态 | **禁止** resume / inject / 改计划 |
|
||||
|
||||
实现要求:
|
||||
|
||||
- 取消:先 `cancel_flag=1`,Runner/Demo 每拍检查;再落 `cancelled`。
|
||||
- 续跑:仅 `awaiting_input → queued`;检查点用 `plan.seatIndex`,勿重新从 0 开场除非产品明确要求。
|
||||
- 人工干预默认安全:`manualIntervention` 缺省视为 **true**(宁可多卡一次)。
|
||||
|
||||
### 3.3 真模型链条语义
|
||||
|
||||
顺序固定:
|
||||
|
||||
1. 校验席位 ≥ 2、模型在 DeerFlow 列表中(未知模型 **拒绝**,禁止静默换模)。
|
||||
2. 无 `threadIds` 则 init;有则续跑。
|
||||
3. 对每个席位:总控派活 → 该席输出 →(若开人工干预且非末席)进入 `awaiting_input`。
|
||||
4. 全席成功后总控收口;交付不全或对话已有 error 气泡 → 不得 `done`。
|
||||
|
||||
一席失败立刻 `error` 停场,禁止「继续跑完再汇总错误」。
|
||||
|
||||
---
|
||||
|
||||
## 4. API 设计要求
|
||||
|
||||
### 4.1 约定
|
||||
|
||||
- 前缀:`/api/v1/analysis`
|
||||
- 包体:`ApiResponse<T>` → `{ code, message, data }`;成功 `code=0`
|
||||
- HTTP:`201` 创建;`404` 找不到;`403` 无权限;`400` 状态/参数不允许
|
||||
- 身份头:`X-Account-Id`、`X-Analysis-Role`(`admin` / `task_owner` / `position_owner` / `viewer`)
|
||||
- 探活:`GET /health` **不鉴权**
|
||||
|
||||
### 4.2 接口清单(保持稳定)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/jobs` | 新建;返回投影后快照 |
|
||||
| GET | `/jobs/{id}` | 轮询 / 重连主路径 |
|
||||
| GET | `/jobs?taskId=&latest=` | 兼容查询;**正式重连勿依赖此接口替代 jobId** |
|
||||
| POST | `/jobs/{id}/cancel` | 仅责任人/创建人/管理员 |
|
||||
| POST | `/jobs/{id}/resume` | 澄清回答或人工「继续」 |
|
||||
| POST | `/jobs/{id}/plan` | 热更新人工干预 / 模型 |
|
||||
| POST | `/jobs/{id}/inject` | 运行中补充;空文本 400 |
|
||||
| GET | `/jobs/{id}/result` | 裁剪字段的结果视图 |
|
||||
| GET | `/health` | 探活 |
|
||||
|
||||
### 4.3 兼容与演进
|
||||
|
||||
- 字段名尽量与前端 `mapJob` 一致,少做二次映射。
|
||||
- 新增字段:只增不改语义;废弃字段先双写/文档标明,再删。
|
||||
- 破坏性变更(状态字符串、必填头、错误码语义)必须同步改 code-zk 客户端与本文,并在 PR 说明迁移步骤。
|
||||
|
||||
---
|
||||
|
||||
## 5. 鉴权与投影
|
||||
|
||||
配置:`analysis.auth.enabled`(本机可 false;内网建议 true)。
|
||||
|
||||
| 角色 | 读 | resume/inject/改 plan | cancel |
|
||||
|------|----|------------------------|--------|
|
||||
| admin / task_owner / 创建人 / access 任务责任人 | 全量 | 是 | 是 |
|
||||
| position_owner(绑定了席位) | 本席 + system/user/leader | 是 | **否**(除非兼责任人) |
|
||||
| 无账号且强制鉴权 | 否 | 否 | 否 |
|
||||
|
||||
投影规则(即使本机关强制鉴权,声明了 `position_owner` 也要裁剪,便于验收):
|
||||
|
||||
- 岗位账号:只见 `system` / `user` / `leader` + 自己 `agentId` 的 special 气泡。
|
||||
- `result.seatDeliveries` 同步裁剪。
|
||||
|
||||
新接口若返回作业快照,必须走 `JobAccessService.projectForCaller`,禁止裸库对象出站。
|
||||
|
||||
---
|
||||
|
||||
## 6. 持久化要求
|
||||
|
||||
- 表:`eba_analysis_job`(见 `schema.sql`);大字段 JSON 文本列。
|
||||
- 正式库:**MySQL utf8mb4**;H2 仅应急联调。
|
||||
- 写库路径统一经 `JobRepository`;流式 flush 可节流(约 ≥150ms),但取消旗、状态跃迁必须及时。
|
||||
- 索引:`task_id` / `status` / `owner_account_id` 保持;新增查询条件先补索引再上线。
|
||||
- 启动执行 `schema.sql` 允许 `continue-on-error`(重复索引);**禁止**靠静默失败掩盖真实 DDL 错误而不验收。
|
||||
|
||||
---
|
||||
|
||||
## 7. DeerFlow 集成要求
|
||||
|
||||
1. **仅服务端**持有 Gateway 账号;口令走环境变量,不写死进正式配置提交物。
|
||||
2. Token 复用并提前刷新;并发登录要有同步,避免打爆登录接口。
|
||||
3. `assertModelAvailable`:指定模型不在列表 → 开跑前失败;禁止依赖 Gateway「自动换模」。
|
||||
4. SSE:识别 `event:error`、`model_switch`、无终帧;一律映射为 `invalidDelivery` / 业务失败。
|
||||
5. 流式展示:`reasoning_content` 与正文一并折成 `<think>…</think>` + 正文,保证前端两边都能流。
|
||||
6. 超时:`stream-idle-timeout-ms` 可配;取消检查嵌在读循环里。
|
||||
7. 本服务不得把 DeerFlow 内部错误原文无差别甩给用户;要带「当前模型」提示,便于换模重开。
|
||||
|
||||
---
|
||||
|
||||
## 8. Worker 与并发
|
||||
|
||||
- LiveWorker:FIFO 领 `queued`;先占坑改 `running` 再异步执行;默认并发上限 **2**(可配置化时须有文档)。
|
||||
- 单机进程内线程池即可;多实例部署前必须先设计「领取互斥」(乐观锁 / `FOR UPDATE` / 外部队列),否则禁止双活抢同一作业。
|
||||
- DemoWorker 与 LiveWorker 开关清晰;生产关闭 demo。
|
||||
|
||||
---
|
||||
|
||||
## 9. 配置与环境
|
||||
|
||||
| 项 | 要求 |
|
||||
|----|------|
|
||||
| 端口 | 默认 `18097`;经 Nginx `/analysis-api` 反代 |
|
||||
| 数据源 | `EBA_DB_*` 覆盖;正式禁用默认弱口令 |
|
||||
| DeerFlow | `EBA_DF_*` 覆盖 |
|
||||
| 鉴权 | 内网打开 `analysis.auth.enabled=true` |
|
||||
| 密钥 | 不进 Git;参考 `docs/env.sample` |
|
||||
|
||||
变更配置项必须更新:`application.yml` 注释、`env.sample`、内网迁移文档。
|
||||
|
||||
---
|
||||
|
||||
## 10. 编码与注释规范
|
||||
|
||||
- 语言:JDK **8**;禁止使用仅高版本语法(除非整体升级并改构建)。
|
||||
- 注释:新代码须**详细中文注释**(为什么 / 约定 / 边界),禁止只复述变量名;有用旧注释不得顺手删。
|
||||
- 异常:业务用明确文案;`SecurityException` → 403;`IllegalStateException` / `IllegalArgumentException` → 400。
|
||||
- 日志:见 **§11 日志与收集**;关键作业必须带 `jobId=`;禁止打 token、密码、完整用户隐私对话到 INFO(必要时 DEBUG 且脱敏)。
|
||||
- 测试:至少覆盖状态不允许的操作、权限拒绝、模型校验失败、取消中断;无单测的改动须在 PR 写清手工验收步骤。
|
||||
|
||||
---
|
||||
|
||||
## 11. 日志与收集
|
||||
|
||||
本节是运维可落地的约束:先保证「能查、能关联、不泄密」,再谈集中平台。
|
||||
|
||||
### 11.1 目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|------|------|
|
||||
| 排障 | 任意一场会商能用 `jobId`(必要时再加 `requestId` / `taskId`)串起创建→领取→DeerFlow→卡点→结束 |
|
||||
| 审计 | 谁 cancel / resume / inject / 改 plan 可追溯(带账号头,不带口令) |
|
||||
| 采集 | 日志形态稳定,便于本机落盘或内网 Filebeat/Vector/rsyslog 采集 |
|
||||
| 安全 | 密钥与大段会商正文默认不上日志;完整内容以库表为准 |
|
||||
|
||||
### 11.2 输出与落盘(应用侧)
|
||||
|
||||
1. **默认打 stdout/stderr**(systemd / Docker / 进程托管都能直接收)。
|
||||
2. 正式环境建议同时挂 **滚动文件**(便于采集 agent 扫目录),例如:
|
||||
- 路径:`/var/log/eightbf-analysis/app.log`(可用环境变量覆盖)
|
||||
- 滚动:按天或按大小(如 100MB × 14 份);禁止无限单文件撑爆磁盘
|
||||
3. 使用标准 Logback(Spring Boot 默认即可);自定义 `logback-spring.xml` 时须文档化路径与滚动策略。
|
||||
4. 日志级别:
|
||||
- 生产默认:`com.eightbf.analysis=INFO`,框架 `WARN`
|
||||
- 排障临时:单 logger 升 `DEBUG`,用完改回;禁止长期全局 DEBUG
|
||||
|
||||
### 11.3 格式(方便采集与检索)
|
||||
|
||||
每条业务日志尽量同构,文本键值即可(先不强制 JSON,内网解析成本更低):
|
||||
|
||||
```text
|
||||
2026-11-02 23:40:01.123 INFO [...] c.e.a.worker.LiveWorker - 开始真模型会商 jobId=job_xxx requestId=req_yyy
|
||||
```
|
||||
|
||||
**推荐固定键(有则必带):**
|
||||
|
||||
| 键 | 何时 |
|
||||
|----|------|
|
||||
| `jobId=` | 一切与作业相关的日志 |
|
||||
| `requestId=` | 创建、与前端对账 |
|
||||
| `taskId=` | 仅当本次操作明确带了 task(勿刷屏) |
|
||||
| `accountId=` | cancel / resume / inject / patchPlan(来自请求头) |
|
||||
| `phase=` / `status=` | 状态跃迁、暂停等待 |
|
||||
| `model=` | 模型校验失败、开跑、换模拒绝 |
|
||||
|
||||
禁止:无键的散文导致无法 grep;同一事件重复刷屏(流式 delta **不要**每字打日志)。
|
||||
|
||||
### 11.4 必打事件(最低集合)
|
||||
|
||||
| 级别 | 事件 |
|
||||
|------|------|
|
||||
| INFO | 作业创建;LiveWorker 领取并开始;进入 `awaiting_input`(澄清/人工);resume 重新入队;会商 `done`;`cancelled` |
|
||||
| INFO | DeerFlow 登录成功(只打过期秒数,**不打 token**);init 完成(席位数) |
|
||||
| WARN | Worker/Runner 未捕获异常;DeerFlow HTTP≥400;模型不可用;流无终帧 / `model_switch` 拒绝;取消后仍异常退出 |
|
||||
| ERROR | 进程级故障(数据源不可用、反复登录失败等) |
|
||||
|
||||
HTTP 访问明细优先靠 **Nginx access log**(`/analysis-api`);应用内不必再镜像每一条轮询 GET(会淹没真正故障)。
|
||||
|
||||
### 11.5 脱敏与禁止项
|
||||
|
||||
**禁止写入日志:**
|
||||
|
||||
- DeerFlow / DB 密码、Bearer token、Cookie
|
||||
- 完整 `dialogues` / 模型长回复 / inject 全文(库里已有;日志最多截断摘要 ≤200 字且仅 WARN/ERROR)
|
||||
- 身份证等敏感 PII(本服务一般无;若 payload 带入勿原样打印)
|
||||
|
||||
**允许:** 错误摘要、模型名、agentId、席位序号、HTTP 状态码、耗时毫秒。
|
||||
|
||||
### 11.6 采集与留存(运维侧)
|
||||
|
||||
按内网条件选一种,**应用不绑定某一家商业 APM**:
|
||||
|
||||
```text
|
||||
eightbf-analysis
|
||||
→ stdout 和/或 /var/log/eightbf-analysis/*.log
|
||||
→ Filebeat / Vector / rsyslog
|
||||
→ 内网 Loki / ELK / 现有日志平台
|
||||
Nginx /analysis-api access/error
|
||||
→ 同上(务必带上游 upstream 耗时,便于区分网关与本服务)
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. 采集配置与本服务**同主机交付说明**写进内网迁移或运维手册(路径、用户、权限)。
|
||||
2. 应用日志与 Nginx 日志**时间对齐**(统一时区 `Asia/Shanghai`)。
|
||||
3. 留存:建议热数据 ≥14 天,冷存按等保/单位规定;磁盘告警纳入主机巡检。
|
||||
4. 检索演练:上线后用一场真实 `jobId` 能在平台搜到「创建→开始→结束/失败」完整链,算采集验收通过。
|
||||
|
||||
### 11.7 与业务库的分工
|
||||
|
||||
| 数据 | 放哪 |
|
||||
|------|------|
|
||||
| 会商正文、气泡、检查点 | **MySQL**(权威) |
|
||||
| 何时开始/停/失败、对接 DeerFlow 是否成功 | **日志**(排障) |
|
||||
| 进度百分比给用户看 | `progress` 字段,不是日志 |
|
||||
|
||||
不要把日志当第二份 dialogues 备份。
|
||||
|
||||
### 11.8 可观测(日志之外,可排期)
|
||||
|
||||
最低仍须保留:`GET /api/v1/analysis/health`。
|
||||
|
||||
建议后续:作业耗时、DeerFlow 错误码计数、queued 堆积告警(指标/告警通道另定,不替代日志)。
|
||||
|
||||
内网功能验收仍见 [内网迁移.md](./内网迁移.md)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 12. 变更流程(本仓)
|
||||
|
||||
1. **先方案**:非琐碎改动先说清影响面(状态机 / API / 前端约定 / DeerFlow),用户批准后再写代码。
|
||||
2. **范围克制**:只改任务所需;不顺手重构无关层。
|
||||
3. **联调**:本服务 + code-zk `/analysis-api`;`sim:build` 后走一遍开研讨 / 干预 / 刷新。
|
||||
4. **提交**:仅用户要求时 commit/push;勿夹带密钥与 `target/`、`data/`。
|
||||
5. **文档同步**:行为变了必须改本文相关条款 + README/内网迁移(若涉及部署)。
|
||||
|
||||
---
|
||||
|
||||
## 13. 验收检查表(开发自测)
|
||||
|
||||
- [ ] 新建两场同 `taskId`,互不覆盖;刷新只跟各自 `jobId`
|
||||
- [ ] live 模式关浏览器后作业仍推进或停在明确卡点
|
||||
- [ ] 取消后 SSE/推进停止,状态 `cancelled`
|
||||
- [ ] 人工干预开:每席(末席前)进入 `awaiting_input`;关且在卡点时自动续跑
|
||||
- [ ] inject 后下一席派活带上补充;气泡可见用户原文
|
||||
- [ ] 未知模型开跑失败,不进入 done
|
||||
- [ ] `position_owner` 看不到其它席 special 气泡;不能 cancel
|
||||
- [ ] 失败场无「可解锁下一阶段」的假 summary / 100% 共识
|
||||
- [ ] 一场作业可用 `jobId` 在日志中串起创建→开始→结束/失败;无 token/密码泄露
|
||||
- [ ] 正式环境日志有滚动策略;采集路径写进运维说明并能检索
|
||||
- [ ] 注释与本文冲突时:以代码行为为准并回修文档
|
||||
|
||||
---
|
||||
|
||||
## 14. 反例(明确禁止)
|
||||
|
||||
- 浏览器直连 DeerFlow 跑 8BF 正式会商
|
||||
- 用 `GET ?taskId=` 代替草稿 `jobId` 做刷新重连主路径
|
||||
- 信任 DeerFlow 自动换模并当成功
|
||||
- flush 时用旧内存 `manualIntervention`/`model` 盖掉库里用户热改
|
||||
- 终态仍允许 inject/resume 而不报错
|
||||
- 多实例无互斥双活抢作业
|
||||
- 把演示 DemoWorker 开进生产当真模型
|
||||
- 把 token / 密码 / 完整 dialogues 打进 INFO;流式 delta 逐字打日志
|
||||
- 只写控制台、无滚动、无采集说明就当「生产可运维」
|
||||
|
||||
---
|
||||
|
||||
*文档版本与仓库代码同步维护;重大行为变更请改本节并在提交说明中引用。*
|
||||
@@ -0,0 +1,7 @@
|
||||
module git.gozeros.com/wsk/eightbf-analysis-go
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require github.com/go-sql-driver/mysql v1.8.1
|
||||
|
||||
require filippo.io/edwards25519 v1.1.0 // indirect
|
||||
@@ -0,0 +1,4 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
@@ -0,0 +1,189 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/service"
|
||||
)
|
||||
|
||||
// Handler HTTP 入口,路径与 Java AnalysisJobController 对齐。
|
||||
type Handler struct {
|
||||
Jobs *service.JobService
|
||||
}
|
||||
|
||||
func (h *Handler) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/analysis/health", h.health)
|
||||
mux.HandleFunc("POST /api/v1/analysis/jobs", h.create)
|
||||
mux.HandleFunc("GET /api/v1/analysis/jobs/{id}", h.get)
|
||||
mux.HandleFunc("GET /api/v1/analysis/jobs", h.getByTask)
|
||||
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/cancel", h.cancel)
|
||||
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/resume", h.resume)
|
||||
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/plan", h.patchPlan)
|
||||
mux.HandleFunc("POST /api/v1/analysis/jobs/{id}/inject", h.inject)
|
||||
mux.HandleFunc("GET /api/v1/analysis/jobs/{id}/result", h.result)
|
||||
}
|
||||
|
||||
func (h *Handler) caller(r *http.Request) service.Caller {
|
||||
return service.NewCaller(r.Header.Get("X-Account-Id"), r.Header.Get("X-Analysis-Role"))
|
||||
}
|
||||
|
||||
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"service": "eightbf-analysis-go",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
var body service.CreateJobRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
job, err := h.Jobs.Create(&body, h.caller(r))
|
||||
if err != nil {
|
||||
h.mapErr(w, err)
|
||||
return
|
||||
}
|
||||
Created(w, job)
|
||||
}
|
||||
|
||||
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
job, err := h.Jobs.Get(id, h.caller(r))
|
||||
if err != nil {
|
||||
h.mapErr(w, err)
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
Fail(w, http.StatusNotFound, 404, "job not found")
|
||||
return
|
||||
}
|
||||
OK(w, job)
|
||||
}
|
||||
|
||||
func (h *Handler) getByTask(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := strings.TrimSpace(r.URL.Query().Get("taskId"))
|
||||
if taskID == "" {
|
||||
Fail(w, http.StatusBadRequest, 400, "taskId required")
|
||||
return
|
||||
}
|
||||
latest := r.URL.Query().Get("latest") == "true"
|
||||
job, err := h.Jobs.GetByTaskID(taskID, latest, h.caller(r))
|
||||
if err != nil {
|
||||
h.mapErr(w, err)
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
Fail(w, http.StatusNotFound, 404, "job not found for taskId")
|
||||
return
|
||||
}
|
||||
OK(w, job)
|
||||
}
|
||||
|
||||
func (h *Handler) cancel(w http.ResponseWriter, r *http.Request) {
|
||||
job, err := h.Jobs.Cancel(r.PathValue("id"), h.caller(r))
|
||||
if err != nil {
|
||||
h.mapErr(w, err)
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
Fail(w, http.StatusNotFound, 404, "job not found")
|
||||
return
|
||||
}
|
||||
OK(w, job)
|
||||
}
|
||||
|
||||
func (h *Handler) resume(w http.ResponseWriter, r *http.Request) {
|
||||
var body service.ResumeJobRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
job, err := h.Jobs.Resume(r.PathValue("id"), &body, h.caller(r))
|
||||
if err != nil {
|
||||
h.mapErr(w, err)
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
Fail(w, http.StatusNotFound, 404, "job not found")
|
||||
return
|
||||
}
|
||||
OK(w, job)
|
||||
}
|
||||
|
||||
func (h *Handler) patchPlan(w http.ResponseWriter, r *http.Request) {
|
||||
var body service.PatchJobPlanRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
job, err := h.Jobs.PatchPlan(r.PathValue("id"), &body, h.caller(r))
|
||||
if err != nil {
|
||||
h.mapErr(w, err)
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
Fail(w, http.StatusNotFound, 404, "job not found")
|
||||
return
|
||||
}
|
||||
OK(w, job)
|
||||
}
|
||||
|
||||
func (h *Handler) inject(w http.ResponseWriter, r *http.Request) {
|
||||
var body service.InjectJobRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
job, err := h.Jobs.Inject(r.PathValue("id"), &body, h.caller(r))
|
||||
if err != nil {
|
||||
h.mapErr(w, err)
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
Fail(w, http.StatusNotFound, 404, "job not found")
|
||||
return
|
||||
}
|
||||
OK(w, job)
|
||||
}
|
||||
|
||||
func (h *Handler) result(w http.ResponseWriter, r *http.Request) {
|
||||
job, err := h.Jobs.Get(r.PathValue("id"), h.caller(r))
|
||||
if err != nil {
|
||||
h.mapErr(w, err)
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
Fail(w, http.StatusNotFound, 404, "job not found")
|
||||
return
|
||||
}
|
||||
OK(w, map[string]interface{}{
|
||||
"id": job.ID,
|
||||
"status": job.Status,
|
||||
"result": job.Result,
|
||||
"error": job.Error,
|
||||
"dialogues": job.Dialogues,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) mapErr(w http.ResponseWriter, err error) {
|
||||
var be *service.BizError
|
||||
if errors.As(err, &be) {
|
||||
switch {
|
||||
case errors.Is(be.Kind, service.ErrForbidden):
|
||||
Fail(w, http.StatusForbidden, 403, be.Msg)
|
||||
case errors.Is(be.Kind, service.ErrBadState), errors.Is(be.Kind, service.ErrBadArg):
|
||||
Fail(w, http.StatusBadRequest, 400, be.Msg)
|
||||
default:
|
||||
Fail(w, http.StatusInternalServerError, 500, be.Msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
Fail(w, http.StatusInternalServerError, 500, err.Error())
|
||||
}
|
||||
|
||||
// WithCORS 本机联调放开跨域(经反代同源时无影响)。
|
||||
func WithCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ApiResponse 与 Java 一致:{ code, message, data };成功 code=0。
|
||||
type ApiResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func OK(w http.ResponseWriter, data interface{}) {
|
||||
writeJSON(w, http.StatusOK, ApiResponse{Code: 0, Message: "ok", Data: data})
|
||||
}
|
||||
|
||||
func Created(w http.ResponseWriter, data interface{}) {
|
||||
writeJSON(w, http.StatusCreated, ApiResponse{Code: 0, Message: "ok", Data: data})
|
||||
}
|
||||
|
||||
func Fail(w http.ResponseWriter, httpStatus, code int, msg string) {
|
||||
writeJSON(w, httpStatus, ApiResponse{Code: code, Message: msg, Data: nil})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config 运行配置(环境变量优先,与 Java EBA_* / analysis.* 对齐)。
|
||||
type Config struct {
|
||||
Port int
|
||||
|
||||
// MySQL:优先 EBA_DB_DSN;否则用 HOST/PORT/NAME/USER/PASSWORD 拼,或解析 JDBC URL。
|
||||
DBDSN string
|
||||
|
||||
AnalysisMode string // live | demo
|
||||
AuthEnabled bool
|
||||
DemoEnabled bool
|
||||
LivePollMS int
|
||||
DemoTickMS int
|
||||
|
||||
DeerFlowBaseURL string
|
||||
DeerFlowUser string
|
||||
DeerFlowPassword string
|
||||
DeerFlowCoordinator string
|
||||
DeerFlowStreamIdleTimeout int // 毫秒
|
||||
}
|
||||
|
||||
// Load 从环境变量加载;缺省贴近本机联调。
|
||||
func Load() Config {
|
||||
c := Config{
|
||||
Port: envInt("SERVER_PORT", 18097),
|
||||
AnalysisMode: env("ANALYSIS_MODE", "live"),
|
||||
AuthEnabled: envBool("ANALYSIS_AUTH_ENABLED", false),
|
||||
DemoEnabled: envBool("EBA_WORKER_DEMO_ENABLED", false),
|
||||
LivePollMS: envInt("EBA_WORKER_LIVE_POLL_MS", 1000),
|
||||
DemoTickMS: envInt("EBA_WORKER_TICK_MS", 400),
|
||||
DeerFlowBaseURL: strings.TrimRight(env("EBA_DF_BASE_URL", "http://127.0.0.1:8001"), "/"),
|
||||
DeerFlowUser: env("EBA_DF_USER", "admin"),
|
||||
DeerFlowPassword: env("EBA_DF_PASSWORD", "123ewq"),
|
||||
DeerFlowCoordinator: env("EBA_DF_COORDINATOR", "roundtable-coordinator"),
|
||||
DeerFlowStreamIdleTimeout: envInt("EBA_DF_STREAM_IDLE_TIMEOUT_MS", 180000),
|
||||
}
|
||||
c.DBDSN = resolveDSN()
|
||||
// live 默认关掉 demo;显式 ANALYSIS_MODE=demo 时打开假进度
|
||||
if c.AnalysisMode == "demo" {
|
||||
c.DemoEnabled = true
|
||||
}
|
||||
if c.AnalysisMode == "live" && !envExplicit("EBA_WORKER_DEMO_ENABLED") {
|
||||
c.DemoEnabled = false
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func resolveDSN() string {
|
||||
if d := strings.TrimSpace(os.Getenv("EBA_DB_DSN")); d != "" {
|
||||
return ensureMultiStatements(d)
|
||||
}
|
||||
user := env("EBA_DB_USER", "eba")
|
||||
pass := env("EBA_DB_PASSWORD", "eba_local_dev")
|
||||
jdbc := strings.TrimSpace(os.Getenv("EBA_DB_URL"))
|
||||
if jdbc != "" {
|
||||
if dsn, ok := jdbcToDSN(jdbc, user, pass); ok {
|
||||
return dsn
|
||||
}
|
||||
}
|
||||
host := env("EBA_DB_HOST", "127.0.0.1")
|
||||
port := env("EBA_DB_PORT", "3306")
|
||||
name := env("EBA_DB_NAME", "eightbf_analysis")
|
||||
return user + ":" + pass + "@tcp(" + host + ":" + port + ")/" + name +
|
||||
"?parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true"
|
||||
}
|
||||
|
||||
// jdbcToDSN 把 Java 的 jdbc:mysql://host:port/db?... 转成 Go driver DSN。
|
||||
func jdbcToDSN(jdbc, user, pass string) (string, bool) {
|
||||
s := strings.TrimPrefix(jdbc, "jdbc:mysql://")
|
||||
if s == jdbc {
|
||||
return "", false
|
||||
}
|
||||
hostPath := s
|
||||
query := ""
|
||||
if i := strings.Index(s, "?"); i >= 0 {
|
||||
hostPath = s[:i]
|
||||
query = s[i+1:]
|
||||
}
|
||||
host := hostPath
|
||||
db := "eightbf_analysis"
|
||||
if i := strings.Index(hostPath, "/"); i >= 0 {
|
||||
host = hostPath[:i]
|
||||
db = hostPath[i+1:]
|
||||
}
|
||||
params := "parseTime=true&loc=Asia%2FShanghai&charset=utf8mb4&multiStatements=true"
|
||||
_ = query // Java JDBC 专用参数不照搬
|
||||
return user + ":" + pass + "@tcp(" + host + ")/" + db + "?" + params, true
|
||||
}
|
||||
|
||||
func ensureMultiStatements(dsn string) string {
|
||||
if strings.Contains(dsn, "multiStatements=") {
|
||||
return dsn
|
||||
}
|
||||
if strings.Contains(dsn, "?") {
|
||||
return dsn + "&multiStatements=true"
|
||||
}
|
||||
return dsn + "?multiStatements=true"
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envExplicit(k string) bool {
|
||||
_, ok := os.LookupEnv(k)
|
||||
return ok
|
||||
}
|
||||
|
||||
func envBool(k string, def bool) bool {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
func envInt(k string, def int) int {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package deerflow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/config"
|
||||
)
|
||||
|
||||
// Auth 向 DeerFlow 换 Bearer,过期前复用。
|
||||
type Auth struct {
|
||||
cfg config.Config
|
||||
mu sync.Mutex
|
||||
token string
|
||||
expiresAt time.Time
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewAuth(cfg config.Config) *Auth {
|
||||
return &Auth{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) BearerToken() (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
now := time.Now()
|
||||
if a.token != "" && a.expiresAt.After(now.Add(2*time.Minute)) {
|
||||
return a.token, nil
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"username": a.cfg.DeerFlowUser,
|
||||
"password": a.cfg.DeerFlowPassword,
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodPost, a.cfg.DeerFlowBaseURL+"/api/v1/auth/login/username", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("DeerFlow 登录失败 HTTP %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.AccessToken == "" {
|
||||
return "", fmt.Errorf("DeerFlow 登录响应无 access_token")
|
||||
}
|
||||
if parsed.ExpiresIn <= 0 {
|
||||
parsed.ExpiresIn = 3600
|
||||
}
|
||||
a.token = parsed.AccessToken
|
||||
a.expiresAt = now.Add(time.Duration(parsed.ExpiresIn) * time.Second)
|
||||
return a.token, nil
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
package deerflow
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/config"
|
||||
)
|
||||
|
||||
// Client 调 DeerFlow:模型校验 / init / run/stream。
|
||||
type Client struct {
|
||||
cfg config.Config
|
||||
auth *Auth
|
||||
}
|
||||
|
||||
func NewClient(cfg config.Config, auth *Auth) *Client {
|
||||
return &Client{cfg: cfg, auth: auth}
|
||||
}
|
||||
|
||||
// AssertModelAvailable 未知模型拒绝开跑(禁止依赖网关静默换模)。
|
||||
func (c *Client) AssertModelAvailable(model string) error {
|
||||
want := strings.TrimSpace(model)
|
||||
if want == "" || strings.EqualFold(want, "demo") {
|
||||
return nil
|
||||
}
|
||||
token, err := c.auth.BearerToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, c.cfg.DeerFlowBaseURL+"/api/models", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("无法校验模型列表 HTTP %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var root map[string]interface{}
|
||||
_ = json.Unmarshal(raw, &root)
|
||||
var arr []interface{}
|
||||
if m, ok := root["models"].([]interface{}); ok {
|
||||
arr = m
|
||||
} else {
|
||||
var list []interface{}
|
||||
if err := json.Unmarshal(raw, &list); err == nil {
|
||||
arr = list
|
||||
}
|
||||
}
|
||||
if arr == nil {
|
||||
return fmt.Errorf("DeerFlow /api/models 响应无 models 列表,拒绝使用未知模型「%s」", want)
|
||||
}
|
||||
for _, item := range arr {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprint(m["name"])
|
||||
id := fmt.Sprint(m["model"])
|
||||
display := fmt.Sprint(m["display_name"])
|
||||
if want == name || want == id || want == display {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("所选模型「%s」不在可用列表中(或不可用)。请换可用模型后重新开始会商,不会自动改用其它模型", want)
|
||||
}
|
||||
|
||||
// InitMultiAgent 建多席线程,返回 agentName → threadId。
|
||||
func (c *Client) InitMultiAgent(agentNames []string, mainAgent, model string) (map[string]string, error) {
|
||||
body := map[string]interface{}{
|
||||
"agent_names": agentNames,
|
||||
"main_agent_name": mainAgent,
|
||||
}
|
||||
if m := strings.TrimSpace(model); m != "" && !strings.EqualFold(m, "demo") {
|
||||
body["model"] = m
|
||||
}
|
||||
threadIDs := map[string]string{}
|
||||
var errMsg string
|
||||
err := c.readSSE("/api/multi-agent/init", body, nil, func(event string, parsed map[string]interface{}) (bool, error) {
|
||||
ev := event
|
||||
if ev == "" {
|
||||
ev = fmt.Sprint(parsed["event"])
|
||||
}
|
||||
switch ev {
|
||||
case "seat_ready", "coordinator_ready":
|
||||
agent := strField(parsed, "agent_name", "main_agent_name")
|
||||
tid := strField(parsed, "thread_id")
|
||||
if agent != "" && tid != "" {
|
||||
threadIDs[agent] = tid
|
||||
}
|
||||
case "init_done":
|
||||
if tids, ok := parsed["thread_ids"].(map[string]interface{}); ok {
|
||||
for k, v := range tids {
|
||||
threadIDs[k] = fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
case "error":
|
||||
errMsg = strField(parsed, "detail")
|
||||
if errMsg == "" {
|
||||
errMsg = "init error"
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errMsg != "" {
|
||||
return nil, fmt.Errorf("多智能体初始化失败:%s", errMsg)
|
||||
}
|
||||
if len(threadIDs) == 0 {
|
||||
return nil, fmt.Errorf("多智能体初始化未返回 thread_ids")
|
||||
}
|
||||
log.Printf("DeerFlow init 完成 seats=%d", len(threadIDs))
|
||||
return threadIDs, nil
|
||||
}
|
||||
|
||||
// RunStream 跑一轮流式会商。
|
||||
func (c *Client) RunStream(
|
||||
agentType string,
|
||||
threadIDs map[string]string,
|
||||
agentName, displayName, newMessage, model string,
|
||||
synthesisMode bool,
|
||||
onText TextDeltaHandler,
|
||||
cancel CancelCheck,
|
||||
) (*StreamResult, error) {
|
||||
tidNode := map[string]string{}
|
||||
for k, v := range threadIDs {
|
||||
tidNode[k] = v
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"agent_type": agentType,
|
||||
"d_agent_thread_id": tidNode,
|
||||
"agent_name": agentName,
|
||||
"new_message": newMessage,
|
||||
"thinking_enabled": true,
|
||||
"reasoning_effort": "medium",
|
||||
"subagent_enabled": false,
|
||||
"seat_skill_directive": false,
|
||||
"synthesis_mode": synthesisMode,
|
||||
"suppress_pre_broadcast": true,
|
||||
"parallel_no_file": false,
|
||||
"peer_deliveries": []interface{}{},
|
||||
}
|
||||
if displayName != "" {
|
||||
body["display_name"] = displayName
|
||||
}
|
||||
if m := strings.TrimSpace(model); m != "" && !strings.EqualFold(m, "demo") {
|
||||
body["model"] = m
|
||||
}
|
||||
|
||||
result := &StreamResult{}
|
||||
var contentAcc strings.Builder
|
||||
lastEvent := ""
|
||||
terminal := false
|
||||
display := &streamDisplayAssembler{}
|
||||
|
||||
err := c.readSSE("/api/multi-agent/run/stream", body, cancel, func(event string, parsed map[string]interface{}) (bool, error) {
|
||||
if event != "" {
|
||||
lastEvent = event
|
||||
}
|
||||
if cancel != nil && cancel() {
|
||||
result.Aborted = true
|
||||
return true, nil
|
||||
}
|
||||
if lastEvent == "error" {
|
||||
result.InvalidDelivery = true
|
||||
detail := strField(parsed, "detail", "message", "content")
|
||||
if detail != "" {
|
||||
result.Content = detail
|
||||
} else if contentAcc.Len() > 0 {
|
||||
result.Content = contentAcc.String()
|
||||
} else {
|
||||
result.Content = "模型运行报错"
|
||||
}
|
||||
terminal = true
|
||||
return true, nil
|
||||
}
|
||||
if status, _ := parsed["status"].(string); status == "model_switch" {
|
||||
result.InvalidDelivery = true
|
||||
failed := strField(parsed, "failed_model")
|
||||
next := strField(parsed, "next_model")
|
||||
reason := strField(parsed, "reason_label", "reason")
|
||||
if reason == "" {
|
||||
reason = "调用失败"
|
||||
}
|
||||
msg := "所选模型"
|
||||
if failed != "" {
|
||||
msg += "「" + failed + "」"
|
||||
}
|
||||
msg += "异常(" + reason + ")"
|
||||
if next != "" {
|
||||
msg += ",已拒绝自动切换到「" + next + "」"
|
||||
}
|
||||
msg += "。请换可用模型后重新开始会商。"
|
||||
result.Content = msg
|
||||
terminal = true
|
||||
return true, nil
|
||||
}
|
||||
if isFinalStatusFrame(parsed) {
|
||||
if closeThink := display.closeThinkIfOpen(); closeThink != "" {
|
||||
contentAcc.WriteString(closeThink)
|
||||
if onText != nil {
|
||||
_ = onText(closeThink)
|
||||
}
|
||||
}
|
||||
if c, ok := parsed["content"].(string); ok {
|
||||
result.Content = c
|
||||
}
|
||||
if n, ok := parsed["agent_name"].(string); ok {
|
||||
result.AgentName = n
|
||||
}
|
||||
switch st := parsed["status"].(type) {
|
||||
case string:
|
||||
if st == "clarification" {
|
||||
result.Clarification = true
|
||||
result.ClarificationDetail = strField(parsed, "content")
|
||||
} else if st == "invalid_delivery" || st == "error" {
|
||||
result.InvalidDelivery = true
|
||||
}
|
||||
case []interface{}:
|
||||
for _, row := range st {
|
||||
arr, ok := row.([]interface{})
|
||||
if !ok || len(arr) < 2 {
|
||||
continue
|
||||
}
|
||||
result.Dispatched = append(result.Dispatched, [2]string{fmt.Sprint(arr[0]), fmt.Sprint(arr[1])})
|
||||
}
|
||||
}
|
||||
if result.Content == "" {
|
||||
result.Content = contentAcc.String()
|
||||
}
|
||||
terminal = true
|
||||
return true, nil
|
||||
}
|
||||
if lastEvent == "messages" || lastEvent == "" {
|
||||
delta := display.push(parsed)
|
||||
if delta != "" {
|
||||
contentAcc.WriteString(delta)
|
||||
if onText != nil {
|
||||
if err := onText(delta); err != nil {
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if result.Content == "" {
|
||||
result.Content = contentAcc.String()
|
||||
}
|
||||
if !result.Aborted && !terminal && !result.Clarification {
|
||||
result.InvalidDelivery = true
|
||||
if strings.TrimSpace(result.Content) == "" {
|
||||
result.Content = "流式输出异常结束(无终态帧)"
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) readSSE(
|
||||
path string,
|
||||
body map[string]interface{},
|
||||
cancel CancelCheck,
|
||||
onData func(event string, parsed map[string]interface{}) (stop bool, err error),
|
||||
) error {
|
||||
token, err := c.auth.BearerToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest(http.MethodPost, c.cfg.DeerFlowBaseURL+path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
client := &http.Client{Timeout: time.Duration(c.cfg.DeerFlowStreamIdleTimeout) * time.Millisecond}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("DeerFlow %s HTTP %d: %s", path, resp.StatusCode, string(b))
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// 单行可能很长
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 4*1024*1024)
|
||||
curEvent := ""
|
||||
for scanner.Scan() {
|
||||
if cancel != nil && cancel() {
|
||||
return nil
|
||||
}
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "event:") {
|
||||
curEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "" {
|
||||
continue
|
||||
}
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
|
||||
continue
|
||||
}
|
||||
switch t := parsed.(type) {
|
||||
case map[string]interface{}:
|
||||
stop, err := onData(curEvent, t)
|
||||
if err != nil || stop {
|
||||
return err
|
||||
}
|
||||
case []interface{}:
|
||||
// messages 帧常为数组,包一层给 assembler
|
||||
stop, err := onData(curEvent, map[string]interface{}{"_arr": t})
|
||||
if err != nil || stop {
|
||||
return err
|
||||
}
|
||||
// 同时把数组直接交给 push 路径:用特殊 key 不够;改为在 onData 外再处理
|
||||
_ = t
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func isFinalStatusFrame(v map[string]interface{}) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := v["status"]; !ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := v["content"]; !ok {
|
||||
return false
|
||||
}
|
||||
switch st := v["status"].(type) {
|
||||
case string:
|
||||
return true
|
||||
case []interface{}:
|
||||
for _, item := range st {
|
||||
arr, ok := item.([]interface{})
|
||||
if !ok || len(arr) < 2 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func strField(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
s := strings.TrimSpace(fmt.Sprint(v))
|
||||
if s != "" && s != "<nil>" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// —— 流式展示折算(reasoning → <think>)——
|
||||
|
||||
type streamDisplayAssembler struct {
|
||||
thinkOpen bool
|
||||
reasoningSeen string
|
||||
}
|
||||
|
||||
func (d *streamDisplayAssembler) push(parsed map[string]interface{}) string {
|
||||
var arr []interface{}
|
||||
if a, ok := parsed["_arr"].([]interface{}); ok {
|
||||
arr = a
|
||||
} else {
|
||||
// 兼容:有些路径直接把数组当根;此处若不是 AI chunk 则空
|
||||
return ""
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return ""
|
||||
}
|
||||
obj, ok := arr[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
typ := strings.ToLower(fmt.Sprint(obj["type"]))
|
||||
if typ != "ai" && typ != "aimessagechunk" && typ != "aimessage" {
|
||||
return ""
|
||||
}
|
||||
var reasoning, answer strings.Builder
|
||||
switch c := obj["content"].(type) {
|
||||
case string:
|
||||
answer.WriteString(c)
|
||||
case []interface{}:
|
||||
for _, item := range c {
|
||||
if s, ok := item.(string); ok {
|
||||
answer.WriteString(s)
|
||||
continue
|
||||
}
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bt := strings.ToLower(fmt.Sprint(m["type"]))
|
||||
val := firstText(m, "thinking", "text", "content")
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(bt, "think") || strings.Contains(bt, "reason") {
|
||||
reasoning.WriteString(val)
|
||||
} else {
|
||||
answer.WriteString(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if fromKw := extractReasoning(obj); fromKw != "" {
|
||||
if strings.HasPrefix(fromKw, d.reasoningSeen) {
|
||||
more := fromKw[len(d.reasoningSeen):]
|
||||
if more != "" {
|
||||
reasoning.WriteString(more)
|
||||
}
|
||||
d.reasoningSeen = fromKw
|
||||
} else if !strings.Contains(d.reasoningSeen, fromKw) {
|
||||
reasoning.WriteString(fromKw)
|
||||
d.reasoningSeen += fromKw
|
||||
}
|
||||
}
|
||||
var out strings.Builder
|
||||
if reasoning.Len() > 0 {
|
||||
if !d.thinkOpen {
|
||||
out.WriteString("<think>")
|
||||
d.thinkOpen = true
|
||||
}
|
||||
out.WriteString(reasoning.String())
|
||||
}
|
||||
if answer.Len() > 0 {
|
||||
if d.thinkOpen {
|
||||
out.WriteString("</think>")
|
||||
d.thinkOpen = false
|
||||
}
|
||||
out.WriteString(answer.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func (d *streamDisplayAssembler) closeThinkIfOpen() string {
|
||||
if !d.thinkOpen {
|
||||
return ""
|
||||
}
|
||||
d.thinkOpen = false
|
||||
return "</think>"
|
||||
}
|
||||
|
||||
func extractReasoning(obj map[string]interface{}) string {
|
||||
for _, k := range []string{"reasoning_content", "reasoning"} {
|
||||
if s, ok := obj[k].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if kw, ok := obj["additional_kwargs"].(map[string]interface{}); ok {
|
||||
for _, k := range []string{"reasoning_content", "reasoning"} {
|
||||
if s, ok := kw[k].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstText(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if s, ok := m[k].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package deerflow
|
||||
|
||||
// StreamResult 一次 run/stream 汇总(与 Java StreamResult 对齐)。
|
||||
type StreamResult struct {
|
||||
Content string
|
||||
AgentName string
|
||||
Dispatched [][2]string // [agentId, task]
|
||||
Clarification bool
|
||||
ClarificationDetail string
|
||||
InvalidDelivery bool
|
||||
Aborted bool
|
||||
}
|
||||
|
||||
// TextDeltaHandler 流式正文增量(可含 <think>)。
|
||||
type TextDeltaHandler func(delta string) error
|
||||
|
||||
// CancelCheck 读 SSE 前询问是否已取消。
|
||||
type CancelCheck func() bool
|
||||
@@ -0,0 +1,56 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// AnalysisJob 一场会商作业(服务端「研讨录像带」)。
|
||||
// JSON 字段名与前端 mapJob / Java 实体对齐,少做二次映射。
|
||||
type AnalysisJob struct {
|
||||
ID string `json:"id"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
DraftID string `json:"draftId,omitempty"`
|
||||
DraftKey string `json:"draftKey,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Cycle int `json:"cycle"`
|
||||
ActiveAgentID string `json:"activeAgentId,omitempty"`
|
||||
ConsensusPercentage int `json:"consensusPercentage"`
|
||||
PendingClarification string `json:"pendingClarification,omitempty"`
|
||||
OrchestrationMode string `json:"orchestrationMode,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
OwnerAccountID string `json:"ownerAccountId,omitempty"`
|
||||
Agents []map[string]interface{} `json:"agents"`
|
||||
Dialogues []map[string]interface{} `json:"dialogues"`
|
||||
ThreadIDs map[string]interface{} `json:"threadIds,omitempty"`
|
||||
Plan map[string]interface{} `json:"plan"`
|
||||
Access map[string]interface{} `json:"access"`
|
||||
Progress map[string]interface{} `json:"progress"`
|
||||
Result map[string]interface{} `json:"result,omitempty"`
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// EnsurePlan plan 为空时补可写 Map。
|
||||
func (j *AnalysisJob) EnsurePlan() map[string]interface{} {
|
||||
if j.Plan == nil {
|
||||
j.Plan = map[string]interface{}{}
|
||||
}
|
||||
return j.Plan
|
||||
}
|
||||
|
||||
// EnsureProgress progress 为空时补可写 Map。
|
||||
func (j *AnalysisJob) EnsureProgress() map[string]interface{} {
|
||||
if j.Progress == nil {
|
||||
j.Progress = map[string]interface{}{}
|
||||
}
|
||||
return j.Progress
|
||||
}
|
||||
|
||||
// EnsureDialogues dialogues 为空时补切片。
|
||||
func (j *AnalysisJob) EnsureDialogues() []map[string]interface{} {
|
||||
if j.Dialogues == nil {
|
||||
j.Dialogues = []map[string]interface{}{}
|
||||
}
|
||||
return j.Dialogues
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package domain
|
||||
|
||||
// 会商作业状态(与 Java JobStatus / 前端约定一致,勿自创别名)。
|
||||
const (
|
||||
StatusQueued = "queued"
|
||||
StatusRunning = "running"
|
||||
StatusAwaitingInput = "awaiting_input"
|
||||
StatusDone = "done"
|
||||
StatusError = "error"
|
||||
StatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// IsActive 是否仍可能被 worker 推进(含等人中)。
|
||||
func IsActive(status string) bool {
|
||||
return status == StatusQueued || status == StatusRunning || status == StatusAwaitingInput
|
||||
}
|
||||
|
||||
// IsTerminal 是否已结束,不应再 resume / inject / 改计划。
|
||||
func IsTerminal(status string) bool {
|
||||
return status == StatusDone || status == StatusError || status == StatusCancelled
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
|
||||
)
|
||||
|
||||
// JobRepo 会商作业表读写(eba_analysis_job)。大字段整段 JSON 文本存。
|
||||
type JobRepo struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewJobRepo(db *sql.DB) *JobRepo {
|
||||
return &JobRepo{DB: db}
|
||||
}
|
||||
|
||||
// EnsureSchema 执行建表脚本(幂等;重复索引错误由调用方忽略亦可)。
|
||||
func (r *JobRepo) EnsureSchema(schemaSQL string) error {
|
||||
_, err := r.DB.Exec(schemaSQL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *JobRepo) Insert(job *domain.AnalysisJob) error {
|
||||
now := time.Now()
|
||||
job.CreatedAt = &now
|
||||
job.UpdatedAt = &now
|
||||
_, err := r.DB.Exec(`INSERT INTO eba_analysis_job(
|
||||
id,request_id,task_id,draft_id,draft_key,status,phase,cycle,active_agent_id,
|
||||
consensus_percentage,pending_clarification,orchestration_mode,model,error_message,
|
||||
owner_account_id,agents_json,chain_json,plan_json,access_json,thread_ids_json,
|
||||
progress_json,result_json,cancel_flag,created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?,?)`,
|
||||
job.ID, nullStr(job.RequestID), nullStr(job.TaskID), nullStr(job.DraftID), nullStr(job.DraftKey),
|
||||
job.Status, nullStr(job.Phase), job.Cycle, nullStr(job.ActiveAgentID),
|
||||
job.ConsensusPercentage, nullStr(job.PendingClarification), nullStr(job.OrchestrationMode),
|
||||
nullStr(job.Model), nullStr(job.Error), nullStr(job.OwnerAccountID),
|
||||
mustJSON(job.Agents), mustJSON(job.Dialogues), mustJSON(job.Plan), mustJSON(job.Access),
|
||||
mustJSON(job.ThreadIDs), mustJSON(job.Progress), mustJSON(job.Result),
|
||||
now, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateProgress 全量刷新进度相关列;cancelled 时同步 cancel_flag=1。
|
||||
func (r *JobRepo) UpdateProgress(job *domain.AnalysisJob) error {
|
||||
now := time.Now()
|
||||
job.UpdatedAt = &now
|
||||
cancel := 0
|
||||
if job.Status == domain.StatusCancelled {
|
||||
cancel = 1
|
||||
}
|
||||
_, err := r.DB.Exec(`UPDATE eba_analysis_job SET status=?, phase=?, cycle=?, active_agent_id=?,
|
||||
consensus_percentage=?, pending_clarification=?, model=?, error_message=?, owner_account_id=?,
|
||||
agents_json=?, chain_json=?, plan_json=?, access_json=?, thread_ids_json=?,
|
||||
progress_json=?, result_json=?, cancel_flag=?, updated_at=? WHERE id=?`,
|
||||
job.Status, nullStr(job.Phase), job.Cycle, nullStr(job.ActiveAgentID),
|
||||
job.ConsensusPercentage, nullStr(job.PendingClarification), nullStr(job.Model),
|
||||
nullStr(job.Error), nullStr(job.OwnerAccountID),
|
||||
mustJSON(job.Agents), mustJSON(job.Dialogues), mustJSON(job.Plan), mustJSON(job.Access),
|
||||
mustJSON(job.ThreadIDs), mustJSON(job.Progress), mustJSON(job.Result),
|
||||
cancel, now, job.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *JobRepo) FindByID(id string) (*domain.AnalysisJob, error) {
|
||||
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, nil
|
||||
}
|
||||
return scanJob(rows)
|
||||
}
|
||||
|
||||
func (r *JobRepo) FindActiveByTaskID(taskID string) (*domain.AnalysisJob, error) {
|
||||
if taskID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE task_id=? AND status IN (?,?,?)
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
taskID, domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, nil
|
||||
}
|
||||
return scanJob(rows)
|
||||
}
|
||||
|
||||
func (r *JobRepo) FindLatestByTaskID(taskID string) (*domain.AnalysisJob, error) {
|
||||
if taskID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE task_id=? ORDER BY created_at DESC LIMIT 1`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, nil
|
||||
}
|
||||
return scanJob(rows)
|
||||
}
|
||||
|
||||
func (r *JobRepo) FindActiveJobs() ([]*domain.AnalysisJob, error) {
|
||||
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE status IN (?,?,?) ORDER BY created_at ASC`,
|
||||
domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanJobs(rows)
|
||||
}
|
||||
|
||||
func (r *JobRepo) FindQueuedJobs() ([]*domain.AnalysisJob, error) {
|
||||
rows, err := r.DB.Query(`SELECT * FROM eba_analysis_job WHERE status=? ORDER BY created_at ASC`, domain.StatusQueued)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanJobs(rows)
|
||||
}
|
||||
|
||||
func (r *JobRepo) MarkCancel(id string) (bool, error) {
|
||||
res, err := r.DB.Exec(`UPDATE eba_analysis_job SET cancel_flag=1, updated_at=? WHERE id=? AND status IN (?,?,?)`,
|
||||
time.Now(), id, domain.StatusQueued, domain.StatusRunning, domain.StatusAwaitingInput)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (r *JobRepo) IsCancelFlag(id string) (bool, error) {
|
||||
var v sql.NullInt64
|
||||
err := r.DB.QueryRow(`SELECT cancel_flag FROM eba_analysis_job WHERE id=?`, id).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return v.Valid && v.Int64 == 1, nil
|
||||
}
|
||||
|
||||
func scanJobs(rows *sql.Rows) ([]*domain.AnalysisJob, error) {
|
||||
var out []*domain.AnalysisJob
|
||||
for rows.Next() {
|
||||
j, err := scanJob(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanJob(rows *sql.Rows) (*domain.AnalysisJob, error) {
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vals := make([]interface{}, len(cols))
|
||||
ptrs := make([]interface{}, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := map[string]interface{}{}
|
||||
for i, c := range cols {
|
||||
m[c] = vals[i]
|
||||
}
|
||||
j := &domain.AnalysisJob{}
|
||||
j.ID = asString(m["id"])
|
||||
j.RequestID = asString(m["request_id"])
|
||||
j.TaskID = asString(m["task_id"])
|
||||
j.DraftID = asString(m["draft_id"])
|
||||
j.DraftKey = asString(m["draft_key"])
|
||||
j.Status = asString(m["status"])
|
||||
j.Phase = asString(m["phase"])
|
||||
j.Cycle = asInt(m["cycle"])
|
||||
j.ActiveAgentID = asString(m["active_agent_id"])
|
||||
j.ConsensusPercentage = asInt(m["consensus_percentage"])
|
||||
j.PendingClarification = asString(m["pending_clarification"])
|
||||
j.OrchestrationMode = asString(m["orchestration_mode"])
|
||||
j.Model = asString(m["model"])
|
||||
j.Error = asString(m["error_message"])
|
||||
j.OwnerAccountID = asString(m["owner_account_id"])
|
||||
j.Agents = readList(asString(m["agents_json"]))
|
||||
j.Dialogues = readList(asString(m["chain_json"]))
|
||||
j.Plan = readMap(asString(m["plan_json"]))
|
||||
j.Access = readMap(asString(m["access_json"]))
|
||||
j.ThreadIDs = readMap(asString(m["thread_ids_json"]))
|
||||
j.Progress = readMap(asString(m["progress_json"]))
|
||||
j.Result = readMap(asString(m["result_json"]))
|
||||
if t := asTime(m["created_at"]); t != nil {
|
||||
j.CreatedAt = t
|
||||
}
|
||||
if t := asTime(m["updated_at"]); t != nil {
|
||||
j.UpdatedAt = t
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
func mustJSON(v interface{}) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func readMap(jsonStr string) map[string]interface{} {
|
||||
if jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func readList(jsonStr string) []map[string]interface{} {
|
||||
if jsonStr == "" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var list []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &list); err != nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func nullStr(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func asString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case []byte:
|
||||
return string(t)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func asInt(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
return int(t)
|
||||
case int32:
|
||||
return int(t)
|
||||
case int:
|
||||
return t
|
||||
case []byte:
|
||||
var n int
|
||||
_ = json.Unmarshal(t, &n)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func asTime(v interface{}) *time.Time {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
if t, ok := v.(time.Time); ok {
|
||||
tt := t
|
||||
return &tt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ApplySchema 按语句拆开执行;重复索引等可忽略错误不阻断启动。
|
||||
func (r *JobRepo) ApplySchema(schemaSQL string) {
|
||||
for _, stmt := range splitSQL(schemaSQL) {
|
||||
if _, err := r.DB.Exec(stmt); err != nil {
|
||||
msg := err.Error()
|
||||
// 1061 Duplicate key name / 已存在表
|
||||
if strings.Contains(msg, "1061") || strings.Contains(msg, "1050") ||
|
||||
strings.Contains(msg, "already exists") {
|
||||
continue
|
||||
}
|
||||
log.Printf("schema 语句失败(已跳过): %v | sql=%.80s…", err, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func splitSQL(s string) []string {
|
||||
var out []string
|
||||
var b strings.Builder
|
||||
lines := strings.Split(s, "\n")
|
||||
for _, line := range lines {
|
||||
trim := strings.TrimSpace(line)
|
||||
if trim == "" || strings.HasPrefix(trim, "--") {
|
||||
continue
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
if strings.HasSuffix(trim, ";") {
|
||||
stmt := strings.TrimSpace(b.String())
|
||||
stmt = strings.TrimSuffix(stmt, ";")
|
||||
if stmt != "" {
|
||||
out = append(out, stmt)
|
||||
}
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
if rest := strings.TrimSpace(b.String()); rest != "" {
|
||||
out = append(out, rest)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleTaskOwner = "task_owner"
|
||||
RolePositionOwner = "position_owner"
|
||||
RoleViewer = "viewer"
|
||||
)
|
||||
|
||||
// Caller 当前请求调用方(来自 X-Account-Id / X-Analysis-Role)。
|
||||
type Caller struct {
|
||||
AccountID string
|
||||
Role string
|
||||
}
|
||||
|
||||
func NewCaller(accountID, role string) Caller {
|
||||
r := strings.TrimSpace(role)
|
||||
if r == "" {
|
||||
r = RoleViewer
|
||||
}
|
||||
return Caller{AccountID: strings.TrimSpace(accountID), Role: r}
|
||||
}
|
||||
|
||||
// AccessService 岗位账号限制与对话投影。
|
||||
type AccessService struct {
|
||||
AuthEnabled bool
|
||||
}
|
||||
|
||||
func (a *AccessService) CanMutate(job *domain.AnalysisJob, c Caller) bool {
|
||||
if !a.AuthEnabled {
|
||||
return true
|
||||
}
|
||||
if c.AccountID == "" {
|
||||
return false
|
||||
}
|
||||
if c.Role == RoleAdmin || c.Role == RoleTaskOwner {
|
||||
return true
|
||||
}
|
||||
if c.AccountID == strings.TrimSpace(job.OwnerAccountID) {
|
||||
return true
|
||||
}
|
||||
if a.isListedTaskOwner(job, c.AccountID) {
|
||||
return true
|
||||
}
|
||||
return c.Role == RolePositionOwner && len(a.boundSeats(job, c.AccountID)) > 0
|
||||
}
|
||||
|
||||
func (a *AccessService) CanCancel(job *domain.AnalysisJob, c Caller) bool {
|
||||
if c.Role == RolePositionOwner {
|
||||
if c.AccountID == strings.TrimSpace(job.OwnerAccountID) || a.isListedTaskOwner(job, c.AccountID) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !a.AuthEnabled {
|
||||
return true
|
||||
}
|
||||
if c.AccountID == "" {
|
||||
return false
|
||||
}
|
||||
if c.Role == RoleAdmin || c.Role == RoleTaskOwner {
|
||||
return true
|
||||
}
|
||||
if c.AccountID == strings.TrimSpace(job.OwnerAccountID) {
|
||||
return true
|
||||
}
|
||||
return a.isListedTaskOwner(job, c.AccountID)
|
||||
}
|
||||
|
||||
func (a *AccessService) CanRead(job *domain.AnalysisJob, c Caller) bool {
|
||||
if !a.AuthEnabled {
|
||||
return true
|
||||
}
|
||||
if c.AccountID == "" {
|
||||
return false
|
||||
}
|
||||
if a.CanMutate(job, c) || a.CanCancel(job, c) {
|
||||
return true
|
||||
}
|
||||
return len(a.boundSeats(job, c.AccountID)) > 0
|
||||
}
|
||||
|
||||
// ProjectForCaller 按角色裁剪对话后返回(责任人全量;岗位只见本席+总控/系统)。
|
||||
func (a *AccessService) ProjectForCaller(job *domain.AnalysisJob, c Caller) *domain.AnalysisJob {
|
||||
if job == nil {
|
||||
return nil
|
||||
}
|
||||
if c.AccountID == "" {
|
||||
return job
|
||||
}
|
||||
if c.Role == RoleAdmin || c.Role == RoleTaskOwner ||
|
||||
c.AccountID == strings.TrimSpace(job.OwnerAccountID) ||
|
||||
a.isListedTaskOwner(job, c.AccountID) {
|
||||
return job
|
||||
}
|
||||
if c.Role != RolePositionOwner && !a.AuthEnabled {
|
||||
return job
|
||||
}
|
||||
seats := a.boundSeats(job, c.AccountID)
|
||||
copyJob := *job
|
||||
if len(seats) == 0 {
|
||||
var filtered []map[string]interface{}
|
||||
for _, d := range job.Dialogues {
|
||||
role := asStr(d["role"])
|
||||
if role == "system" || role == "user" {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
copyJob.Dialogues = filtered
|
||||
if job.Result != nil {
|
||||
r := cloneMap(job.Result)
|
||||
delete(r, "seatDeliveries")
|
||||
copyJob.Result = r
|
||||
}
|
||||
return ©Job
|
||||
}
|
||||
var filtered []map[string]interface{}
|
||||
for _, d := range job.Dialogues {
|
||||
role := asStr(d["role"])
|
||||
agentID := asStr(d["agentId"])
|
||||
if role == "system" || role == "user" || role == "leader" {
|
||||
filtered = append(filtered, d)
|
||||
} else if seats[agentID] {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
copyJob.Dialogues = filtered
|
||||
if job.Result != nil {
|
||||
r := cloneMap(job.Result)
|
||||
if raw, ok := r["seatDeliveries"].([]interface{}); ok {
|
||||
var kept []interface{}
|
||||
for _, o := range raw {
|
||||
m, ok := o.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if seats[asStr(m["agentId"])] {
|
||||
kept = append(kept, m)
|
||||
}
|
||||
}
|
||||
r["seatDeliveries"] = kept
|
||||
}
|
||||
copyJob.Result = r
|
||||
}
|
||||
return ©Job
|
||||
}
|
||||
|
||||
func (a *AccessService) boundSeats(job *domain.AnalysisJob, accountID string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if job == nil || accountID == "" || job.Access == nil {
|
||||
return out
|
||||
}
|
||||
raw, ok := job.Access["seatAccounts"]
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
for k, v := range m {
|
||||
if accountInList(v, accountID) {
|
||||
out[k] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *AccessService) isListedTaskOwner(job *domain.AnalysisJob, accountID string) bool {
|
||||
if job == nil || job.Access == nil {
|
||||
return false
|
||||
}
|
||||
return accountInList(job.Access["taskOwnerAccountIds"], accountID) ||
|
||||
accountInList(job.Access["adminAccountIds"], accountID)
|
||||
}
|
||||
|
||||
func accountInList(raw interface{}, accountID string) bool {
|
||||
if raw == nil || accountID == "" {
|
||||
return false
|
||||
}
|
||||
switch t := raw.(type) {
|
||||
case []interface{}:
|
||||
for _, o := range t {
|
||||
if strings.TrimSpace(asStr(o)) == accountID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return strings.TrimSpace(asStr(raw)) == accountID
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMap(m map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asStr(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
|
||||
)
|
||||
|
||||
func TestPositionOwnerProjection(t *testing.T) {
|
||||
a := &AccessService{AuthEnabled: false}
|
||||
job := &domain.AnalysisJob{
|
||||
OwnerAccountID: "owner1",
|
||||
Access: map[string]interface{}{
|
||||
"seatAccounts": map[string]interface{}{
|
||||
"seat-a": []interface{}{"acc-a"},
|
||||
},
|
||||
},
|
||||
Dialogues: []map[string]interface{}{
|
||||
{"role": "leader", "agentId": "coord", "content": "派活"},
|
||||
{"role": "special", "agentId": "seat-a", "content": "本席"},
|
||||
{"role": "special", "agentId": "seat-b", "content": "它席"},
|
||||
{"role": "user", "content": "我说"},
|
||||
},
|
||||
}
|
||||
out := a.ProjectForCaller(job, NewCaller("acc-a", RolePositionOwner))
|
||||
if len(out.Dialogues) != 3 {
|
||||
t.Fatalf("期望 3 条(leader+本席+user),得到 %d", len(out.Dialogues))
|
||||
}
|
||||
for _, d := range out.Dialogues {
|
||||
if asStr(d["agentId"]) == "seat-b" {
|
||||
t.Fatal("岗位责任人不应看到它席气泡")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositionOwnerCannotCancel(t *testing.T) {
|
||||
a := &AccessService{AuthEnabled: false}
|
||||
job := &domain.AnalysisJob{
|
||||
OwnerAccountID: "owner1",
|
||||
Access: map[string]interface{}{
|
||||
"seatAccounts": map[string]interface{}{"seat-a": []interface{}{"acc-a"}},
|
||||
},
|
||||
}
|
||||
if a.CanCancel(job, NewCaller("acc-a", RolePositionOwner)) {
|
||||
t.Fatal("岗位责任人默认不能 cancel")
|
||||
}
|
||||
if !a.CanMutate(job, NewCaller("acc-a", RolePositionOwner)) {
|
||||
t.Fatal("岗位责任人应可 resume/inject")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
|
||||
)
|
||||
|
||||
// DemoWorker 假进度推进(仅 ANALYSIS_MODE=demo)。
|
||||
type DemoWorker struct {
|
||||
Repo *repo.JobRepo
|
||||
Every time.Duration
|
||||
}
|
||||
|
||||
var demoLines = []string{
|
||||
"【演示】我先说观点:当前应先收窄目标,再谈方案细节。",
|
||||
"【演示】节奏上建议短周期验证,避免一次铺太开。",
|
||||
"【演示】依赖和数据口径要尽早对齐,否则后面容易返工。",
|
||||
"【演示】成本先压到能证明价值的最小投入。",
|
||||
"【演示】主要风险是范围蔓延和决策偏慢,需要明确拍板人。",
|
||||
"【演示】我同意形成一版可执行共识,可以进入总结。",
|
||||
}
|
||||
|
||||
const charsPerTick = 8
|
||||
|
||||
func (w *DemoWorker) Start() {
|
||||
if w.Every <= 0 {
|
||||
w.Every = 400 * time.Millisecond
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(w.Every)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
list, err := w.Repo.FindActiveJobs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, job := range list {
|
||||
if err := w.advance(job); err != nil {
|
||||
log.Printf("demo worker advance failed jobId=%s: %v", job.ID, err)
|
||||
job.Status = domain.StatusError
|
||||
job.Phase = "error"
|
||||
job.Error = err.Error()
|
||||
_ = w.Repo.UpdateProgress(job)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *DemoWorker) advance(job *domain.AnalysisJob) error {
|
||||
if ok, _ := w.Repo.IsCancelFlag(job.ID); ok {
|
||||
finishCurrentBubble(job, "cancelled")
|
||||
job.Status = domain.StatusCancelled
|
||||
job.Phase = "cancelled"
|
||||
job.Error = "cancelled"
|
||||
p := job.EnsureProgress()
|
||||
p["message"] = "cancelled"
|
||||
return w.Repo.UpdateProgress(job)
|
||||
}
|
||||
if job.Status == domain.StatusAwaitingInput {
|
||||
return nil
|
||||
}
|
||||
agents := job.Agents
|
||||
if agents == nil {
|
||||
agents = []map[string]interface{}{}
|
||||
}
|
||||
agentCount := len(agents)
|
||||
if agentCount < 1 {
|
||||
agentCount = 1
|
||||
}
|
||||
dialogues := job.EnsureDialogues()
|
||||
progress := job.EnsureProgress()
|
||||
|
||||
if job.Status == domain.StatusQueued {
|
||||
job.Status = domain.StatusRunning
|
||||
job.Phase = "analyzing"
|
||||
job.Cycle = 1
|
||||
progress["percent"] = 5
|
||||
progress["message"] = "席位开始发言"
|
||||
progress["lineIndex"] = 0
|
||||
progress["charIndex"] = 0
|
||||
applyAgentStatus(agents, 0, "speaking")
|
||||
job.ActiveAgentID = agentIDAt(agents, 0)
|
||||
job.Agents = agents
|
||||
openDemoBubble(job, dialogues, agents, 0, "席位输出中…")
|
||||
// openDemoBubble 已写回 job.Dialogues;切勿再用旧 dialogues 切片盖掉新气泡
|
||||
return w.Repo.UpdateProgress(job)
|
||||
}
|
||||
|
||||
lineIndex := toIntVal(progress["lineIndex"], 0)
|
||||
charIndex := toIntVal(progress["charIndex"], 0)
|
||||
|
||||
if lineIndex < len(demoLines) {
|
||||
speakerIdx := lineIndex % agentCount
|
||||
wantAgent := agentIDAt(agents, speakerIdx)
|
||||
if job.ActiveAgentID != wantAgent || (charIndex == 0 && !hasStreaming(dialogues)) {
|
||||
if charIndex == 0 {
|
||||
finishCurrentBubble(job, "")
|
||||
for i := range agents {
|
||||
st := "listening"
|
||||
if i == speakerIdx {
|
||||
st = "speaking"
|
||||
}
|
||||
applyAgentStatus(agents, i, st)
|
||||
}
|
||||
job.ActiveAgentID = wantAgent
|
||||
openDemoBubble(job, dialogues, agents, speakerIdx, "席位输出中…")
|
||||
dialogues = job.Dialogues
|
||||
}
|
||||
}
|
||||
line := demoLines[lineIndex]
|
||||
end := charIndex + charsPerTick
|
||||
if end > len([]rune(line)) {
|
||||
end = len([]rune(line))
|
||||
}
|
||||
rs := []rune(line)
|
||||
if end > charIndex {
|
||||
appendStreaming(dialogues, string(rs[charIndex:end]))
|
||||
charIndex = end
|
||||
}
|
||||
if charIndex >= len(rs) {
|
||||
appendStreaming(dialogues, "\n\n")
|
||||
finishCurrentBubble(job, "")
|
||||
lineIndex++
|
||||
charIndex = 0
|
||||
}
|
||||
totalChars := 0
|
||||
for _, l := range demoLines {
|
||||
totalChars += len([]rune(l))
|
||||
}
|
||||
doneChars := 0
|
||||
for i := 0; i < lineIndex && i < len(demoLines); i++ {
|
||||
doneChars += len([]rune(demoLines[i]))
|
||||
}
|
||||
doneChars += charIndex
|
||||
percent := 5 + (doneChars*85)/max(totalChars, 1)
|
||||
if percent > 90 {
|
||||
percent = 90
|
||||
}
|
||||
progress["lineIndex"] = lineIndex
|
||||
progress["charIndex"] = charIndex
|
||||
progress["percent"] = percent
|
||||
progress["message"] = "发言中 " + itoa(percent) + "%"
|
||||
job.Cycle = 1 + lineIndex/max(agentCount, 1)
|
||||
job.ConsensusPercentage = min(95, percent)
|
||||
job.Agents = agents
|
||||
job.Dialogues = dialogues
|
||||
return w.Repo.UpdateProgress(job)
|
||||
}
|
||||
|
||||
finishCurrentBubble(job, "")
|
||||
summary := "【演示总结】以上均为占位发言,用于验证「关浏览器后任务仍继续 + 页面流式展示」。" +
|
||||
"接入真实模型后,这里会换成总控根据各席发言生成的真正总结。" +
|
||||
"(job=" + job.ID + ")"
|
||||
dialogues = append(dialogues, map[string]interface{}{
|
||||
"id": "dlg_" + job.ID + "_leader", "role": "leader", "sender": "总控协调",
|
||||
"agentId": "coordinator", "content": summary, "status": "done", "phaseLabel": "全案盖印",
|
||||
})
|
||||
for i := range agents {
|
||||
applyAgentStatus(agents, i, "done")
|
||||
}
|
||||
job.ActiveAgentID = ""
|
||||
job.Agents = agents
|
||||
job.Status = domain.StatusDone
|
||||
job.Phase = "done"
|
||||
job.ConsensusPercentage = 100
|
||||
progress["percent"] = 100
|
||||
progress["message"] = "completed"
|
||||
job.Dialogues = dialogues
|
||||
job.Result = map[string]interface{}{
|
||||
"summary": summary,
|
||||
"consensusPercentage": 100,
|
||||
"recommendations": []string{"确认产品边界与里程碑", "补充关键风险与资源假设", "安排下一轮执行评审"},
|
||||
}
|
||||
return w.Repo.UpdateProgress(job)
|
||||
}
|
||||
|
||||
func openDemoBubble(job *domain.AnalysisJob, dialogues, agents []map[string]interface{}, idx int, phase string) {
|
||||
bubble := map[string]interface{}{
|
||||
"id": "dlg_" + job.ID + "_" + itoa(len(dialogues)+1), "role": "special",
|
||||
"sender": agentNameAt(agents, idx), "agentId": agentIDAt(agents, idx),
|
||||
"content": "", "status": "streaming", "phaseLabel": phase,
|
||||
}
|
||||
job.Dialogues = append(dialogues, bubble)
|
||||
}
|
||||
|
||||
func finishCurrentBubble(job *domain.AnalysisJob, override string) {
|
||||
for i := len(job.Dialogues) - 1; i >= 0; i-- {
|
||||
b := job.Dialogues[i]
|
||||
if asStr(b["status"]) == "streaming" {
|
||||
st := "done"
|
||||
if override != "" {
|
||||
st = override
|
||||
}
|
||||
b["status"] = st
|
||||
b["phaseLabel"] = ""
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendStreaming(dialogues []map[string]interface{}, chunk string) {
|
||||
for i := len(dialogues) - 1; i >= 0; i-- {
|
||||
b := dialogues[i]
|
||||
if asStr(b["status"]) == "streaming" {
|
||||
b["content"] = asStr(b["content"]) + chunk
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasStreaming(dialogues []map[string]interface{}) bool {
|
||||
for i := len(dialogues) - 1; i >= 0; i-- {
|
||||
if asStr(dialogues[i]["status"]) == "streaming" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func agentIDAt(agents []map[string]interface{}, idx int) string {
|
||||
if len(agents) == 0 {
|
||||
return ""
|
||||
}
|
||||
return asStr(agents[idx%len(agents)]["id"])
|
||||
}
|
||||
|
||||
func agentNameAt(agents []map[string]interface{}, idx int) string {
|
||||
if len(agents) == 0 {
|
||||
return "席位"
|
||||
}
|
||||
a := agents[idx%len(agents)]
|
||||
if n := asStr(a["name"]); n != "" {
|
||||
return n
|
||||
}
|
||||
return asStr(a["id"])
|
||||
}
|
||||
|
||||
func applyAgentStatus(agents []map[string]interface{}, idx int, status string) {
|
||||
if idx < 0 || idx >= len(agents) {
|
||||
return
|
||||
}
|
||||
agents[idx]["status"] = status
|
||||
}
|
||||
|
||||
func toIntVal(v interface{}, def int) int {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
return t
|
||||
case int64:
|
||||
return int(t)
|
||||
case float64:
|
||||
return int(t)
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package service
|
||||
|
||||
import "errors"
|
||||
|
||||
// 业务错误:HTTP 层按类型映射 403/400。
|
||||
var (
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrBadState = errors.New("bad state")
|
||||
ErrBadArg = errors.New("bad argument")
|
||||
)
|
||||
|
||||
// BizError 带给人看的文案。
|
||||
type BizError struct {
|
||||
Kind error
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *BizError) Error() string { return e.Msg }
|
||||
func (e *BizError) Unwrap() error { return e.Kind }
|
||||
|
||||
func Forbidden(msg string) error { return &BizError{Kind: ErrForbidden, Msg: msg} }
|
||||
func BadState(msg string) error { return &BizError{Kind: ErrBadState, Msg: msg} }
|
||||
func BadArg(msg string) error { return &BizError{Kind: ErrBadArg, Msg: msg} }
|
||||
@@ -0,0 +1,352 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
|
||||
)
|
||||
|
||||
// CreateJobRequest 创建会商作业入参。
|
||||
type CreateJobRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
TaskID string `json:"taskId"`
|
||||
DraftID string `json:"draftId"`
|
||||
DraftKey string `json:"draftKey"`
|
||||
Model string `json:"model"`
|
||||
OrchestrationMode string `json:"orchestrationMode"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
OwnerAccountID string `json:"ownerAccountId"`
|
||||
ManualIntervention *bool `json:"manualIntervention"`
|
||||
Agents []map[string]interface{} `json:"agents"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
Access map[string]interface{} `json:"access"`
|
||||
}
|
||||
|
||||
type ResumeJobRequest struct {
|
||||
Answer string `json:"answer"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type PatchJobPlanRequest struct {
|
||||
ManualIntervention *bool `json:"manualIntervention"`
|
||||
Model *string `json:"model"`
|
||||
}
|
||||
|
||||
type InjectJobRequest struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// JobService 会商作业编排(不直接调大模型)。
|
||||
type JobService struct {
|
||||
Repo *repo.JobRepo
|
||||
Access *AccessService
|
||||
}
|
||||
|
||||
func (s *JobService) Create(req *CreateJobRequest, caller Caller) (*domain.AnalysisJob, error) {
|
||||
if req == nil {
|
||||
req = &CreateJobRequest{}
|
||||
}
|
||||
manual := true
|
||||
if req.ManualIntervention != nil {
|
||||
manual = *req.ManualIntervention
|
||||
}
|
||||
owner := blankToNull(req.OwnerAccountID)
|
||||
if owner == "" && caller.AccountID != "" {
|
||||
owner = caller.AccountID
|
||||
}
|
||||
mode := blankToNull(req.OrchestrationMode)
|
||||
if mode == "" {
|
||||
mode = "roundtable"
|
||||
}
|
||||
job := &domain.AnalysisJob{
|
||||
ID: "job_" + randHex(8),
|
||||
RequestID: firstNonEmpty(blankToNull(req.RequestID), "req_"+randHex(6)),
|
||||
TaskID: blankToNull(req.TaskID),
|
||||
DraftID: blankToNull(req.DraftID),
|
||||
DraftKey: blankToNull(req.DraftKey),
|
||||
Status: domain.StatusQueued,
|
||||
Phase: "queued",
|
||||
OrchestrationMode: mode,
|
||||
Model: strings.TrimSpace(req.Model),
|
||||
OwnerAccountID: owner,
|
||||
Agents: normalizeAgents(req.Agents),
|
||||
Dialogues: []map[string]interface{}{},
|
||||
Access: orEmptyMap(req.Access),
|
||||
Plan: map[string]interface{}{
|
||||
"seatIndex": 0,
|
||||
"manualIntervention": manual,
|
||||
"seedMessage": nil,
|
||||
"pendingInject": nil,
|
||||
"awaitReason": nil,
|
||||
},
|
||||
Progress: map[string]interface{}{
|
||||
"percent": 0,
|
||||
"message": "queued",
|
||||
"title": req.Title,
|
||||
"description": req.Description,
|
||||
},
|
||||
}
|
||||
if req.Payload != nil {
|
||||
job.Progress["payload"] = req.Payload
|
||||
}
|
||||
if err := s.Repo.Insert(job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Access.ProjectForCaller(job, caller), nil
|
||||
}
|
||||
|
||||
func (s *JobService) Get(id string, caller Caller) (*domain.AnalysisJob, error) {
|
||||
job, err := s.Repo.FindByID(id)
|
||||
if err != nil || job == nil {
|
||||
return job, err
|
||||
}
|
||||
if !s.Access.CanRead(job, caller) {
|
||||
return nil, Forbidden("无权查看该会商作业")
|
||||
}
|
||||
return s.Access.ProjectForCaller(job, caller), nil
|
||||
}
|
||||
|
||||
func (s *JobService) GetByTaskID(taskID string, latestOnly bool, caller Caller) (*domain.AnalysisJob, error) {
|
||||
var job *domain.AnalysisJob
|
||||
var err error
|
||||
if latestOnly {
|
||||
job, err = s.Repo.FindLatestByTaskID(taskID)
|
||||
} else {
|
||||
job, err = s.Repo.FindActiveByTaskID(taskID)
|
||||
if err == nil && job == nil {
|
||||
job, err = s.Repo.FindLatestByTaskID(taskID)
|
||||
}
|
||||
}
|
||||
if err != nil || job == nil {
|
||||
return job, err
|
||||
}
|
||||
if !s.Access.CanRead(job, caller) {
|
||||
return nil, Forbidden("无权查看该会商作业")
|
||||
}
|
||||
return s.Access.ProjectForCaller(job, caller), nil
|
||||
}
|
||||
|
||||
func (s *JobService) Cancel(id string, caller Caller) (*domain.AnalysisJob, error) {
|
||||
job, err := s.Repo.FindByID(id)
|
||||
if err != nil || job == nil {
|
||||
return job, err
|
||||
}
|
||||
if !s.Access.CanCancel(job, caller) {
|
||||
return nil, Forbidden("无权取消该会商作业(仅任务责任人/创建人/管理员)")
|
||||
}
|
||||
if domain.IsTerminal(job.Status) {
|
||||
return s.Access.ProjectForCaller(job, caller), nil
|
||||
}
|
||||
_, _ = s.Repo.MarkCancel(id)
|
||||
job.Status = domain.StatusCancelled
|
||||
job.Phase = "cancelled"
|
||||
job.Error = "cancelled by client"
|
||||
p := job.EnsureProgress()
|
||||
p["message"] = "cancelled"
|
||||
if err := s.Repo.UpdateProgress(job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Access.ProjectForCaller(job, caller), nil
|
||||
}
|
||||
|
||||
func (s *JobService) Resume(id string, body *ResumeJobRequest, caller Caller) (*domain.AnalysisJob, error) {
|
||||
job, err := s.Repo.FindByID(id)
|
||||
if err != nil || job == nil {
|
||||
return job, err
|
||||
}
|
||||
if !s.Access.CanMutate(job, caller) {
|
||||
return nil, Forbidden("无权续跑该会商作业")
|
||||
}
|
||||
if job.Status != domain.StatusAwaitingInput {
|
||||
return nil, BadState("当前状态不是等待输入,无法 resume(status=" + job.Status + ")")
|
||||
}
|
||||
answer := ""
|
||||
if body != nil {
|
||||
answer = strings.TrimSpace(body.Answer)
|
||||
}
|
||||
plan := job.EnsurePlan()
|
||||
if answer != "" {
|
||||
appendUserDialogue(job, answer)
|
||||
prev := asStr(plan["pendingInject"])
|
||||
merged := answer
|
||||
if strings.TrimSpace(prev) != "" {
|
||||
merged = prev + "\n\n" + answer
|
||||
}
|
||||
plan["pendingInject"] = "用户对澄清的回复:\n" + merged
|
||||
}
|
||||
plan["awaitReason"] = nil
|
||||
job.PendingClarification = ""
|
||||
job.Status = domain.StatusQueued
|
||||
job.Phase = "resume_queued"
|
||||
p := job.EnsureProgress()
|
||||
p["message"] = "已收到补充,准备继续"
|
||||
if err := s.Repo.UpdateProgress(job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fresh, err := s.Repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Access.ProjectForCaller(fresh, caller), nil
|
||||
}
|
||||
|
||||
func (s *JobService) PatchPlan(id string, body *PatchJobPlanRequest, caller Caller) (*domain.AnalysisJob, error) {
|
||||
job, err := s.Repo.FindByID(id)
|
||||
if err != nil || job == nil {
|
||||
return job, err
|
||||
}
|
||||
if !s.Access.CanMutate(job, caller) {
|
||||
return nil, Forbidden("无权修改该会商作业设置")
|
||||
}
|
||||
if domain.IsTerminal(job.Status) {
|
||||
return nil, BadState("作业已结束,无法修改设置")
|
||||
}
|
||||
if body == nil {
|
||||
return s.Access.ProjectForCaller(job, caller), nil
|
||||
}
|
||||
touched := false
|
||||
plan := job.EnsurePlan()
|
||||
progress := job.EnsureProgress()
|
||||
if body.Model != nil {
|
||||
model := strings.TrimSpace(*body.Model)
|
||||
job.Model = model
|
||||
if model == "" {
|
||||
progress["message"] = "已清除模型指定(用默认)"
|
||||
} else {
|
||||
progress["message"] = "已切换模型为 " + model
|
||||
}
|
||||
touched = true
|
||||
}
|
||||
if body.ManualIntervention != nil {
|
||||
manual := *body.ManualIntervention
|
||||
plan["manualIntervention"] = manual
|
||||
if manual {
|
||||
progress["message"] = "已开启人工干预(下一席结束后将等待确认)"
|
||||
} else {
|
||||
progress["message"] = "已关闭人工干预(将自动连跑)"
|
||||
}
|
||||
touched = true
|
||||
atGate := job.Status == domain.StatusAwaitingInput && asStr(plan["awaitReason"]) == "manual_continue"
|
||||
if !manual && atGate {
|
||||
plan["awaitReason"] = nil
|
||||
job.PendingClarification = ""
|
||||
job.Status = domain.StatusQueued
|
||||
job.Phase = "resume_queued"
|
||||
progress["message"] = "已关闭人工干预,自动继续下一席"
|
||||
}
|
||||
}
|
||||
if !touched {
|
||||
return s.Access.ProjectForCaller(job, caller), nil
|
||||
}
|
||||
if err := s.Repo.UpdateProgress(job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fresh, err := s.Repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Access.ProjectForCaller(fresh, caller), nil
|
||||
}
|
||||
|
||||
func (s *JobService) Inject(id string, body *InjectJobRequest, caller Caller) (*domain.AnalysisJob, error) {
|
||||
job, err := s.Repo.FindByID(id)
|
||||
if err != nil || job == nil {
|
||||
return job, err
|
||||
}
|
||||
if !s.Access.CanMutate(job, caller) {
|
||||
return nil, Forbidden("无权向该会商注入消息")
|
||||
}
|
||||
text := ""
|
||||
if body != nil {
|
||||
text = strings.TrimSpace(body.Text)
|
||||
}
|
||||
if text == "" {
|
||||
return nil, BadArg("注入内容不能为空")
|
||||
}
|
||||
if domain.IsTerminal(job.Status) {
|
||||
return nil, BadState("作业已结束,无法注入")
|
||||
}
|
||||
appendUserDialogue(job, text)
|
||||
plan := job.EnsurePlan()
|
||||
prev := asStr(plan["pendingInject"])
|
||||
merged := text
|
||||
if strings.TrimSpace(prev) != "" {
|
||||
merged = prev + "\n\n" + text
|
||||
}
|
||||
plan["pendingInject"] = "用户向会商插入的补充说明:\n" + merged
|
||||
p := job.EnsureProgress()
|
||||
p["message"] = "已收到用户补充,将在下一轮派活时带入"
|
||||
if err := s.Repo.UpdateProgress(job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fresh, err := s.Repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Access.ProjectForCaller(fresh, caller), nil
|
||||
}
|
||||
|
||||
func appendUserDialogue(job *domain.AnalysisJob, text string) {
|
||||
list := job.EnsureDialogues()
|
||||
bubble := map[string]interface{}{
|
||||
"id": "dlg_" + job.ID + "_user_" + strconv.Itoa(len(list)+1),
|
||||
"role": "user",
|
||||
"sender": "我",
|
||||
"content": text,
|
||||
"status": "done",
|
||||
"phaseLabel": "",
|
||||
}
|
||||
job.Dialogues = append(list, bubble)
|
||||
}
|
||||
|
||||
func normalizeAgents(agents []map[string]interface{}) []map[string]interface{} {
|
||||
if len(agents) > 0 {
|
||||
out := make([]map[string]interface{}, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
copy := cloneMap(a)
|
||||
if _, ok := copy["id"]; !ok {
|
||||
if aid, ok := copy["agentId"]; ok {
|
||||
copy["id"] = aid
|
||||
}
|
||||
}
|
||||
if _, ok := copy["status"]; !ok {
|
||||
copy["status"] = "idle"
|
||||
}
|
||||
out = append(out, copy)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return []map[string]interface{}{
|
||||
{"id": "local-flow-stub-intel", "name": "联调桩-情报采集", "status": "idle"},
|
||||
{"id": "local-flow-stub-plan", "name": "联调桩-筹划规划", "status": "idle"},
|
||||
}
|
||||
}
|
||||
|
||||
func blankToNull(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func orEmptyMap(m map[string]interface{}) map[string]interface{} {
|
||||
if m == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func randHex(nBytes int) string {
|
||||
b := make([]byte, nBytes)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/deerflow"
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
|
||||
)
|
||||
|
||||
// LiveChainRunner 真模型按链条顺序执行(被 LiveWorker 异步调用)。
|
||||
type LiveChainRunner struct {
|
||||
DF *deerflow.Client
|
||||
Repo *repo.JobRepo
|
||||
Coord string
|
||||
|
||||
mu sync.Mutex
|
||||
lastFlush time.Time
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) Run(job *domain.AnalysisJob) {
|
||||
jobID := job.ID
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("真模型会商 panic jobId=%s: %v", jobID, rec)
|
||||
r.fail(job, fmt.Sprint(rec))
|
||||
}
|
||||
}()
|
||||
|
||||
agents := job.Agents
|
||||
if len(agents) < 2 {
|
||||
r.fail(job, "有效席位不足 2 个,无法启动真模型会商")
|
||||
return
|
||||
}
|
||||
var agentNames []string
|
||||
for _, a := range agents {
|
||||
id := asStr(a["id"])
|
||||
if id != "" {
|
||||
agentNames = append(agentNames, id)
|
||||
}
|
||||
}
|
||||
if len(agentNames) < 2 {
|
||||
r.fail(job, "席位 agentId 无效(需要 DeerFlow 的 hyphen-case id)")
|
||||
return
|
||||
}
|
||||
|
||||
plan := ensurePlan(job)
|
||||
r.pullModelFromDB(job)
|
||||
if err := r.DF.AssertModelAvailable(job.Model); err != nil {
|
||||
r.fail(job, err.Error())
|
||||
return
|
||||
}
|
||||
job.Status = domain.StatusRunning
|
||||
job.Phase = "running"
|
||||
r.flush(job)
|
||||
|
||||
if r.cancelled(jobID) {
|
||||
r.markCancelled(job)
|
||||
return
|
||||
}
|
||||
|
||||
threadIDs := loadThreadIDs(job)
|
||||
if len(threadIDs) == 0 {
|
||||
putProgress(job, 2, "正在初始化 DeerFlow 席位线程…")
|
||||
r.flush(job)
|
||||
r.pullModelFromDB(job)
|
||||
tids, err := r.DF.InitMultiAgent(agentNames, r.Coord, job.Model)
|
||||
if err != nil {
|
||||
r.fail(job, err.Error())
|
||||
return
|
||||
}
|
||||
threadIDs = tids
|
||||
store := map[string]interface{}{}
|
||||
for k, v := range tids {
|
||||
store[k] = v
|
||||
}
|
||||
job.ThreadIDs = store
|
||||
putProgress(job, 8, "席位线程已就绪,开始链条研讨")
|
||||
r.flush(job)
|
||||
} else {
|
||||
pct := toInt(job.Progress["percent"], 8)
|
||||
if pct < 8 {
|
||||
pct = 8
|
||||
}
|
||||
putProgress(job, pct, "从检查点继续会商")
|
||||
r.flush(job)
|
||||
}
|
||||
|
||||
seed := asStr(plan["seedMessage"])
|
||||
if seed == "" {
|
||||
seed = BuildSeed(job.Progress)
|
||||
plan["seedMessage"] = seed
|
||||
}
|
||||
if inj := consumePendingInject(plan); inj != "" {
|
||||
seed = seed + "\n\n" + inj
|
||||
plan["seedMessage"] = seed
|
||||
}
|
||||
|
||||
total := len(agentNames)
|
||||
start := toInt(plan["seatIndex"], 0)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
seatDeliveries := loadDeliveries(job)
|
||||
lastLeader := ""
|
||||
|
||||
for i := start; i < total; i++ {
|
||||
if r.cancelled(jobID) {
|
||||
r.markCancelled(job)
|
||||
return
|
||||
}
|
||||
r.pullManualFromDB(job, plan)
|
||||
r.pullInjectFromDB(job, plan)
|
||||
r.pullModelFromDB(job)
|
||||
model := job.Model
|
||||
if mid := consumePendingInject(plan); mid != "" {
|
||||
seed = seed + "\n\n" + mid
|
||||
plan["seedMessage"] = seed
|
||||
}
|
||||
plan["seatIndex"] = i
|
||||
job.Plan = plan
|
||||
|
||||
seat := agents[i]
|
||||
seatID := asStr(seat["id"])
|
||||
seatName := firstNonEmpty(asStr(seat["name"]), seatID)
|
||||
job.ActiveAgentID = r.Coord
|
||||
job.Cycle = i + 1
|
||||
applyAgentSpeaking(agents, seatID, false)
|
||||
job.Agents = agents
|
||||
|
||||
leaderBubbleID := openBubble(job, "leader", "总控协调", r.Coord, "链条派活中")
|
||||
r.flush(job)
|
||||
leaderRes, err := r.DF.RunStream("leader", threadIDs, r.Coord, "总控协调",
|
||||
BuildLeaderTask(seed, i, total, seatID, seatName, i == 0),
|
||||
model, false,
|
||||
r.deltaHandler(job, leaderBubbleID),
|
||||
func() bool { return r.cancelled(jobID) },
|
||||
)
|
||||
if err != nil {
|
||||
finishBubble(job, leaderBubbleID, err.Error(), "error")
|
||||
r.fail(job, buildModelHint("总控派活失败", model, err.Error()))
|
||||
return
|
||||
}
|
||||
if r.cancelled(jobID) || leaderRes.Aborted {
|
||||
finishBubble(job, leaderBubbleID, leaderRes.Content, "stopped")
|
||||
r.markCancelled(job)
|
||||
return
|
||||
}
|
||||
if leaderRes.Clarification {
|
||||
finishBubble(job, leaderBubbleID, leaderRes.Content, "clarification")
|
||||
detail := leaderRes.ClarificationDetail
|
||||
if detail == "" {
|
||||
detail = leaderRes.Content
|
||||
}
|
||||
r.pauseForInput(job, plan, i, "clarification", detail)
|
||||
return
|
||||
}
|
||||
leaderBad := leaderRes.InvalidDelivery || strings.TrimSpace(leaderRes.Content) == "" || looksLikeModelFailure(leaderRes.Content)
|
||||
if leaderBad {
|
||||
finishBubble(job, leaderBubbleID, leaderRes.Content, "error")
|
||||
r.fail(job, buildModelHint("总控派活失败", model, leaderRes.Content))
|
||||
return
|
||||
}
|
||||
finishBubble(job, leaderBubbleID, leaderRes.Content, "done")
|
||||
lastLeader = leaderRes.Content
|
||||
r.flush(job)
|
||||
|
||||
task := PickChainTask(leaderRes.Dispatched, leaderRes.Content, seatID, seatName)
|
||||
job.ActiveAgentID = seatID
|
||||
applyAgentSpeaking(agents, seatID, true)
|
||||
job.Agents = agents
|
||||
seatBubbleID := openBubble(job, "special", seatName, seatID, "席位输出中…")
|
||||
r.flush(job)
|
||||
seatRes, err := r.DF.RunStream("special", threadIDs, seatID, seatName, task, model, false,
|
||||
r.deltaHandler(job, seatBubbleID),
|
||||
func() bool { return r.cancelled(jobID) },
|
||||
)
|
||||
if err != nil {
|
||||
finishBubble(job, seatBubbleID, err.Error(), "error")
|
||||
r.fail(job, buildModelHint("席位「"+seatName+"」输出失败", model, err.Error()))
|
||||
return
|
||||
}
|
||||
if r.cancelled(jobID) || seatRes.Aborted {
|
||||
finishBubble(job, seatBubbleID, seatRes.Content, "stopped")
|
||||
r.markCancelled(job)
|
||||
return
|
||||
}
|
||||
bad := seatRes.InvalidDelivery || strings.TrimSpace(seatRes.Content) == "" || looksLikeModelFailure(seatRes.Content)
|
||||
status := "done"
|
||||
if bad {
|
||||
status = "error"
|
||||
}
|
||||
finishBubble(job, seatBubbleID, seatRes.Content, status)
|
||||
if bad {
|
||||
r.fail(job, buildModelHint("席位「"+seatName+"」输出失败", model, seatRes.Content))
|
||||
return
|
||||
}
|
||||
seatDeliveries = append(seatDeliveries, map[string]interface{}{
|
||||
"sender": seatName,
|
||||
"agentId": seatID,
|
||||
"content": seatRes.Content,
|
||||
})
|
||||
saveDeliveries(job, seatDeliveries)
|
||||
pct := (i + 1) * 85 / total
|
||||
if pct > 90 {
|
||||
pct = 90
|
||||
}
|
||||
job.ConsensusPercentage = pct
|
||||
putProgress(job, pct, fmt.Sprintf("已完成 %d/%d 席", i+1, total))
|
||||
plan["seatIndex"] = i + 1
|
||||
job.Plan = plan
|
||||
r.flush(job)
|
||||
|
||||
r.pullManualFromDB(job, plan)
|
||||
if readManual(plan) && i+1 < total {
|
||||
r.pauseForInput(job, plan, i+1, "manual_continue",
|
||||
fmt.Sprintf("人工干预:第 %d/%d 席已完成,请确认后继续下一席。", i+1, total))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.cancelled(jobID) {
|
||||
r.markCancelled(job)
|
||||
return
|
||||
}
|
||||
job.ActiveAgentID = r.Coord
|
||||
r.pullModelFromDB(job)
|
||||
closeModel := job.Model
|
||||
closeID := openBubble(job, "leader", "总控协调", r.Coord, "全案盖印")
|
||||
r.flush(job)
|
||||
closeRes, err := r.DF.RunStream("leader", threadIDs, r.Coord, "总控协调", Closeout, closeModel, true,
|
||||
r.deltaHandler(job, closeID),
|
||||
func() bool { return r.cancelled(jobID) },
|
||||
)
|
||||
if err != nil {
|
||||
finishBubble(job, closeID, err.Error(), "error")
|
||||
r.fail(job, buildModelHint("全案收口失败", closeModel, err.Error()))
|
||||
return
|
||||
}
|
||||
if r.cancelled(jobID) || closeRes.Aborted {
|
||||
finishBubble(job, closeID, closeRes.Content, "stopped")
|
||||
r.markCancelled(job)
|
||||
return
|
||||
}
|
||||
if closeRes.InvalidDelivery || looksLikeModelFailure(closeRes.Content) ||
|
||||
(strings.TrimSpace(closeRes.Content) == "" && strings.TrimSpace(lastLeader) == "") {
|
||||
finishBubble(job, closeID, closeRes.Content, "error")
|
||||
r.fail(job, buildModelHint("全案收口失败", closeModel, closeRes.Content))
|
||||
return
|
||||
}
|
||||
if len(seatDeliveries) < total {
|
||||
finishBubble(job, closeID, closeRes.Content, "error")
|
||||
r.fail(job, fmt.Sprintf("会商未完整交付(成功 %d/%d 席),请检查模型后重新开始。", len(seatDeliveries), total))
|
||||
return
|
||||
}
|
||||
if dialoguesHaveError(job) {
|
||||
finishBubble(job, closeID, closeRes.Content, "error")
|
||||
r.fail(job, "会商过程中已有失败发言,不能进入收口/下一阶段。请换可用模型后重新开始。")
|
||||
return
|
||||
}
|
||||
summary := closeRes.Content
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = lastLeader
|
||||
}
|
||||
finishBubble(job, closeID, summary, "done")
|
||||
for _, a := range agents {
|
||||
a["status"] = "done"
|
||||
}
|
||||
job.Agents = agents
|
||||
job.ActiveAgentID = ""
|
||||
job.Status = domain.StatusDone
|
||||
job.Phase = "done"
|
||||
job.ConsensusPercentage = 100
|
||||
putProgress(job, 100, "completed")
|
||||
job.Result = map[string]interface{}{
|
||||
"summary": summary,
|
||||
"consensusPercentage": 100,
|
||||
"seatDeliveries": seatDeliveries,
|
||||
}
|
||||
plan["awaitReason"] = nil
|
||||
job.Plan = plan
|
||||
r.flush(job)
|
||||
log.Printf("真模型会商完成 jobId=%s", jobID)
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) pauseForInput(job *domain.AnalysisJob, plan map[string]interface{}, next int, reason, detail string) {
|
||||
plan["seatIndex"] = next
|
||||
plan["awaitReason"] = reason
|
||||
job.Plan = plan
|
||||
job.Status = domain.StatusAwaitingInput
|
||||
job.Phase = reason
|
||||
job.PendingClarification = detail
|
||||
msg := "等待用户澄清"
|
||||
if reason == "manual_continue" {
|
||||
msg = "等待人工确认继续"
|
||||
}
|
||||
putProgress(job, job.ConsensusPercentage, msg)
|
||||
r.flush(job)
|
||||
log.Printf("作业暂停等待输入 jobId=%s reason=%s", job.ID, reason)
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) deltaHandler(job *domain.AnalysisJob, bubbleID string) deerflow.TextDeltaHandler {
|
||||
return func(delta string) error {
|
||||
appendBubble(job, bubbleID, delta)
|
||||
r.throttleFlush(job)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) throttleFlush(job *domain.AnalysisJob) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if time.Since(r.lastFlush) >= 150*time.Millisecond {
|
||||
r.flushLocked(job)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) flush(job *domain.AnalysisJob) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.flushLocked(job)
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) flushLocked(job *domain.AnalysisJob) {
|
||||
plan := ensurePlan(job)
|
||||
r.pullManualFromDB(job, plan)
|
||||
r.pullModelFromDB(job)
|
||||
injectConsumed := plan["_injectConsumed"] == true
|
||||
if !injectConsumed {
|
||||
if strings.TrimSpace(asStr(plan["pendingInject"])) == "" {
|
||||
r.pullInjectFromDB(job, plan)
|
||||
}
|
||||
}
|
||||
delete(plan, "_injectConsumed")
|
||||
job.Plan = plan
|
||||
_ = r.Repo.UpdateProgress(job)
|
||||
r.lastFlush = time.Now()
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) cancelled(jobID string) bool {
|
||||
ok, err := r.Repo.IsCancelFlag(jobID)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) markCancelled(job *domain.AnalysisJob) {
|
||||
job.Status = domain.StatusCancelled
|
||||
job.Phase = "cancelled"
|
||||
job.Error = "cancelled"
|
||||
putProgress(job, job.ConsensusPercentage, "cancelled")
|
||||
r.flush(job)
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) fail(job *domain.AnalysisJob, message string) {
|
||||
job.Status = domain.StatusError
|
||||
job.Phase = "error"
|
||||
job.Error = message
|
||||
job.ConsensusPercentage = 0
|
||||
if job.Result != nil {
|
||||
delete(job.Result, "summary")
|
||||
job.Result["consensusPercentage"] = 0
|
||||
job.Result["failed"] = true
|
||||
}
|
||||
putProgress(job, 0, "error")
|
||||
r.flush(job)
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) pullModelFromDB(job *domain.AnalysisJob) {
|
||||
fresh, err := r.Repo.FindByID(job.ID)
|
||||
if err != nil || fresh == nil {
|
||||
return
|
||||
}
|
||||
job.Model = fresh.Model
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) pullManualFromDB(job *domain.AnalysisJob, plan map[string]interface{}) {
|
||||
fresh, err := r.Repo.FindByID(job.ID)
|
||||
if err != nil || fresh == nil || fresh.Plan == nil {
|
||||
return
|
||||
}
|
||||
if v, ok := fresh.Plan["manualIntervention"]; ok {
|
||||
plan["manualIntervention"] = v
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LiveChainRunner) pullInjectFromDB(job *domain.AnalysisJob, plan map[string]interface{}) {
|
||||
fresh, err := r.Repo.FindByID(job.ID)
|
||||
if err != nil || fresh == nil || fresh.Plan == nil {
|
||||
return
|
||||
}
|
||||
dbText := strings.TrimSpace(asStr(fresh.Plan["pendingInject"]))
|
||||
if dbText == "" {
|
||||
return
|
||||
}
|
||||
local := strings.TrimSpace(asStr(plan["pendingInject"]))
|
||||
if local == "" {
|
||||
plan["pendingInject"] = dbText
|
||||
} else if !strings.Contains(local, dbText) {
|
||||
plan["pendingInject"] = local + "\n\n" + dbText
|
||||
}
|
||||
}
|
||||
|
||||
func consumePendingInject(plan map[string]interface{}) string {
|
||||
s := strings.TrimSpace(asStr(plan["pendingInject"]))
|
||||
plan["pendingInject"] = nil
|
||||
plan["_injectConsumed"] = true
|
||||
return s
|
||||
}
|
||||
|
||||
func ensurePlan(job *domain.AnalysisJob) map[string]interface{} {
|
||||
p := job.EnsurePlan()
|
||||
if _, ok := p["seatIndex"]; !ok {
|
||||
p["seatIndex"] = 0
|
||||
}
|
||||
if _, ok := p["manualIntervention"]; !ok {
|
||||
p["manualIntervention"] = true
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func putProgress(job *domain.AnalysisJob, percent int, message string) {
|
||||
p := job.EnsureProgress()
|
||||
p["percent"] = percent
|
||||
p["message"] = message
|
||||
}
|
||||
|
||||
func loadThreadIDs(job *domain.AnalysisJob) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range job.ThreadIDs {
|
||||
if v != nil {
|
||||
out[k] = fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadDeliveries(job *domain.AnalysisJob) []map[string]interface{} {
|
||||
if job.Result == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := job.Result["seatDeliveries"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch t := raw.(type) {
|
||||
case []map[string]interface{}:
|
||||
return t
|
||||
case []interface{}:
|
||||
var out []map[string]interface{}
|
||||
for _, o := range t {
|
||||
if m, ok := o.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func saveDeliveries(job *domain.AnalysisJob, list []map[string]interface{}) {
|
||||
if job.Result == nil {
|
||||
job.Result = map[string]interface{}{}
|
||||
}
|
||||
job.Result["seatDeliveries"] = list
|
||||
}
|
||||
|
||||
func readManual(plan map[string]interface{}) bool {
|
||||
if plan == nil {
|
||||
return true
|
||||
}
|
||||
v, ok := plan["manualIntervention"]
|
||||
if !ok || v == nil {
|
||||
return true
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
default:
|
||||
return !strings.EqualFold(strings.TrimSpace(fmt.Sprint(t)), "false")
|
||||
}
|
||||
}
|
||||
|
||||
func openBubble(job *domain.AnalysisJob, role, sender, agentID, phase string) string {
|
||||
list := job.EnsureDialogues()
|
||||
id := fmt.Sprintf("dlg_%s_%d", job.ID, len(list)+1)
|
||||
job.Dialogues = append(list, map[string]interface{}{
|
||||
"id": id, "role": role, "sender": sender, "agentId": agentID,
|
||||
"content": "", "status": "streaming", "phaseLabel": phase,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
func appendBubble(job *domain.AnalysisJob, bubbleID, delta string) {
|
||||
if delta == "" {
|
||||
return
|
||||
}
|
||||
for _, b := range job.Dialogues {
|
||||
if asStr(b["id"]) == bubbleID {
|
||||
b["content"] = asStr(b["content"]) + delta
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finishBubble(job *domain.AnalysisJob, bubbleID, finalContent, status string) {
|
||||
for _, b := range job.Dialogues {
|
||||
if asStr(b["id"]) != bubbleID {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(finalContent) != "" {
|
||||
prev := asStr(b["content"])
|
||||
b["content"] = mergeKeepThinking(prev, finalContent)
|
||||
}
|
||||
if status == "" {
|
||||
status = "done"
|
||||
}
|
||||
b["status"] = status
|
||||
b["phaseLabel"] = ""
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func mergeKeepThinking(streamed, finalContent string) string {
|
||||
if strings.Contains(finalContent, "<think>") || strings.Contains(finalContent, "</think>") {
|
||||
return finalContent
|
||||
}
|
||||
open := strings.Index(streamed, "<think>")
|
||||
if open < 0 {
|
||||
return finalContent
|
||||
}
|
||||
close := strings.LastIndex(streamed, "</think>")
|
||||
if close >= open {
|
||||
return streamed[:close+len("</think>")] + "\n" + finalContent
|
||||
}
|
||||
return streamed + "</think>\n" + finalContent
|
||||
}
|
||||
|
||||
func applyAgentSpeaking(agents []map[string]interface{}, speakingID string, seatTurn bool) {
|
||||
for _, a := range agents {
|
||||
id := asStr(a["id"])
|
||||
if seatTurn && id == speakingID {
|
||||
a["status"] = "speaking"
|
||||
} else {
|
||||
a["status"] = "listening"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dialoguesHaveError(job *domain.AnalysisJob) bool {
|
||||
for _, b := range job.Dialogues {
|
||||
if strings.EqualFold(asStr(b["status"]), "error") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func looksLikeModelFailure(content string) bool {
|
||||
t := strings.TrimSpace(content)
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(t)
|
||||
needles := []string{"model_not_found", "invalid model", "does not exist", "no such model",
|
||||
"litellm", "aperror", "apierror", "authenticationerror", "rate_limit", "context_length"}
|
||||
for _, n := range needles {
|
||||
if strings.Contains(lower, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
cn := []string{"大模型本次调用未能成功完成", "请稍后再试或更换模型", "大模型因鉴权失败",
|
||||
"大模型服务暂时繁忙", "大模型响应暂时中断", "大模型当前响应异常", "已被熔断保护",
|
||||
"已拒绝自动切换", "模型不存在", "无可用模型", "模型不可用", "鉴权失败"}
|
||||
for _, n := range cn {
|
||||
if strings.Contains(t, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if utf8.RuneCountInString(t) < 400 && len(t) >= 5 && t[0] == '[' && t[4] == ']' {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildModelHint(head, model, detail string) string {
|
||||
sb := head
|
||||
if strings.TrimSpace(model) != "" {
|
||||
sb += "(当前模型:" + strings.TrimSpace(model) + ")"
|
||||
}
|
||||
sb += "。请换可用模型后重新开始会商。"
|
||||
d := strings.TrimSpace(detail)
|
||||
if d != "" {
|
||||
if utf8.RuneCountInString(d) > 180 {
|
||||
rs := []rune(d)
|
||||
d = string(rs[:180]) + "…"
|
||||
}
|
||||
sb += " 详情:" + d
|
||||
}
|
||||
return sb
|
||||
}
|
||||
|
||||
func toInt(v interface{}, def int) int {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
return t
|
||||
case int64:
|
||||
return int(t)
|
||||
case float64:
|
||||
return int(t)
|
||||
case string:
|
||||
var n int
|
||||
_, err := fmt.Sscanf(t, "%d", &n)
|
||||
if err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/domain"
|
||||
"git.gozeros.com/wsk/eightbf-analysis-go/internal/repo"
|
||||
)
|
||||
|
||||
// LiveWorker 定时扫 queued,最多并发 2 场交给 LiveChainRunner。
|
||||
type LiveWorker struct {
|
||||
Repo *repo.JobRepo
|
||||
Runner *LiveChainRunner
|
||||
PollEvery time.Duration
|
||||
inflight int32
|
||||
}
|
||||
|
||||
func (w *LiveWorker) Start() {
|
||||
if w.PollEvery <= 0 {
|
||||
w.PollEvery = time.Second
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(w.PollEvery)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
w.poll()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *LiveWorker) poll() {
|
||||
if atomic.LoadInt32(&w.inflight) >= 2 {
|
||||
return
|
||||
}
|
||||
queued, err := w.Repo.FindQueuedJobs()
|
||||
if err != nil {
|
||||
log.Printf("LiveWorker 扫队列失败: %v", err)
|
||||
return
|
||||
}
|
||||
for _, job := range queued {
|
||||
if atomic.LoadInt32(&w.inflight) >= 2 {
|
||||
break
|
||||
}
|
||||
job.Status = domain.StatusRunning
|
||||
job.Phase = "claimed"
|
||||
if err := w.Repo.UpdateProgress(job); err != nil {
|
||||
log.Printf("LiveWorker 占坑失败 jobId=%s: %v", job.ID, err)
|
||||
continue
|
||||
}
|
||||
atomic.AddInt32(&w.inflight, 1)
|
||||
jobID := job.ID
|
||||
go func() {
|
||||
defer atomic.AddInt32(&w.inflight, -1)
|
||||
fresh, err := w.Repo.FindByID(jobID)
|
||||
if err != nil || fresh == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("开始真模型会商 jobId=%s", jobID)
|
||||
w.Runner.Run(fresh)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 链条提示词(与 Java ChainPrompts / 前端历史行为对齐)。
|
||||
|
||||
const Lang = "【语言要求】你的全部可见输出(派活说明、任务描述、分析、结论)必须使用简体中文;" +
|
||||
"禁止用英文句子作答。agent_name / 技能名等标识符可保持原文,正文一律中文。"
|
||||
|
||||
const Closeout = "各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。\n" + Lang
|
||||
|
||||
func BuildSeed(progress map[string]interface{}) string {
|
||||
if progress == nil {
|
||||
return "请基于用户任务意图,调度子智能体完成研讨。"
|
||||
}
|
||||
var sb strings.Builder
|
||||
title := asStr(progress["title"])
|
||||
desc := asStr(progress["description"])
|
||||
if payload, ok := progress["payload"].(map[string]interface{}); ok {
|
||||
if intent, ok := payload["intent"].(map[string]interface{}); ok {
|
||||
obj := firstNonEmpty(asStr(intent["objective"]), asStr(intent["summary"]), title)
|
||||
if obj != "" {
|
||||
sb.WriteString(obj)
|
||||
}
|
||||
if list, ok := intent["constraints"].([]interface{}); ok && len(list) > 0 {
|
||||
sb.WriteString("\n\n约束条件:\n")
|
||||
for _, c := range list {
|
||||
sb.WriteString("- ")
|
||||
sb.WriteString(fmt.Sprint(c))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if list, ok := intent["assumptions"].([]interface{}); ok && len(list) > 0 {
|
||||
sb.WriteString("\n关键假设:\n")
|
||||
for _, a := range list {
|
||||
sb.WriteString("- ")
|
||||
sb.WriteString(fmt.Sprint(a))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
sb.WriteString(title)
|
||||
if desc != "" {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
sb.WriteString(desc)
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "请基于用户此前澄清的任务意图,调度子智能体完成研讨。"
|
||||
}
|
||||
sb.WriteString("\n\n用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func BuildLeaderTask(seed string, position, total int, seatAgentID, seatName string, isFirst bool) string {
|
||||
ordinal := fmt.Sprintf("%d/%d", position+1, total)
|
||||
if isFirst {
|
||||
return "这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:\n" +
|
||||
seed + "\n\n" +
|
||||
"本次研讨严格按预设链条顺序进行,共 " + fmt.Sprint(total) + " 个席位,现在是第 " + ordinal + " 个席位。\n" +
|
||||
"请你作为总控,通过 agent_orchestration 技能**仅**向智能体「" + seatName +
|
||||
"」(agent_name: " + seatAgentID + ")派活,结合任务背景为它分配聚焦其职责的本轮任务。\n" +
|
||||
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。\n" +
|
||||
"派活说明与交给该席位的任务正文必须是简体中文(可写成:本轮派活给「" +
|
||||
seatName + "」……),不要输出英文派活句。\n" + Lang
|
||||
}
|
||||
return "链条研讨继续,现在轮到第 " + ordinal + " 个席位。\n" +
|
||||
"请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体「" +
|
||||
seatName + "」(agent_name: " + seatAgentID +
|
||||
")派活,为它分配承接前序结论、聚焦其职责的本轮任务。\n" +
|
||||
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。\n" +
|
||||
"派活说明与任务正文必须是简体中文。\n" + Lang
|
||||
}
|
||||
|
||||
func PickChainTask(dispatched [][2]string, leaderContent, seatAgentID, seatName string) string {
|
||||
wanted := strings.ToLower(seatAgentID)
|
||||
for _, row := range dispatched {
|
||||
if strings.ToLower(row[0]) == wanted && strings.TrimSpace(row[1]) != "" {
|
||||
return withLang(row[1])
|
||||
}
|
||||
}
|
||||
for _, row := range dispatched {
|
||||
if strings.TrimSpace(row[1]) != "" {
|
||||
return withLang(row[1])
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(leaderContent) != "" {
|
||||
return withLang("请你作为「" + seatName + "」,基于以下总控说明与此前各席位的交付,从你的专业职责出发给出你的分析与交付:\n" + strings.TrimSpace(leaderContent))
|
||||
}
|
||||
return withLang("请你作为「" + seatName + "」,基于任务背景与此前各席位的交付,从你的专业职责出发给出你的分析与交付。")
|
||||
}
|
||||
|
||||
func withLang(body string) string { return Lang + "\n" + body }
|
||||
|
||||
func asStr(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
|
||||
func firstNonEmpty(parts ...string) string {
|
||||
for _, p := range parts {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
return strings.TrimSpace(p)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildSeedAndPickTask(t *testing.T) {
|
||||
seed := BuildSeed(map[string]interface{}{
|
||||
"title": "测试任务",
|
||||
"payload": map[string]interface{}{
|
||||
"intent": map[string]interface{}{
|
||||
"objective": "完成联调",
|
||||
"constraints": []interface{}{"必须中文"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(seed, "完成联调") {
|
||||
t.Fatalf("seed 异常: %s", seed)
|
||||
}
|
||||
task := PickChainTask([][2]string{{"agent-x", "请分析风险"}}, "总控全文", "agent-x", "风险岗")
|
||||
if !strings.Contains(task, "请分析风险") || !strings.Contains(task, "简体中文") {
|
||||
t.Fatalf("pick 任务异常: %s", task)
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
-- 会商作业主表:一条记录 = 一次后台会商
|
||||
-- 本机 / 正式默认走 MySQL(utf8mb4);H2 profile 仍可跑同一份(LONGTEXT≈CLOB)
|
||||
CREATE TABLE IF NOT EXISTS eba_analysis_job (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
request_id VARCHAR(64),
|
||||
task_id VARCHAR(128),
|
||||
draft_id VARCHAR(64),
|
||||
draft_key VARCHAR(255),
|
||||
status VARCHAR(32) NOT NULL,
|
||||
phase VARCHAR(32),
|
||||
cycle INT DEFAULT 0,
|
||||
active_agent_id VARCHAR(128),
|
||||
consensus_percentage INT DEFAULT 0,
|
||||
pending_clarification LONGTEXT,
|
||||
orchestration_mode VARCHAR(32),
|
||||
model VARCHAR(128),
|
||||
error_message LONGTEXT,
|
||||
-- 创建人账号;岗位限制与检查点见 access_json / plan_json
|
||||
owner_account_id VARCHAR(128),
|
||||
agents_json LONGTEXT,
|
||||
chain_json LONGTEXT, -- dialogues 气泡列表
|
||||
plan_json LONGTEXT, -- 检查点:seatIndex、manualIntervention、pendingInject 等
|
||||
access_json LONGTEXT, -- 权限:taskOwnerAccountIds、seatAccounts、adminAccountIds
|
||||
thread_ids_json LONGTEXT,
|
||||
progress_json LONGTEXT,
|
||||
result_json LONGTEXT,
|
||||
cancel_flag TINYINT DEFAULT 0,
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- MySQL 无「IF NOT EXISTS」索引时,重复执行靠 continue-on-error 忽略
|
||||
CREATE INDEX idx_eba_job_task ON eba_analysis_job(task_id);
|
||||
CREATE INDEX idx_eba_job_status ON eba_analysis_job(status);
|
||||
CREATE INDEX idx_eba_job_owner ON eba_analysis_job(owner_account_id);
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# 对本机已启动的 Go 服务做 demo 冒烟(默认 :18097)。
|
||||
# 注意:勿与 Java 实例同时抢同一库的 queued。
|
||||
set -euo pipefail
|
||||
BASE="${BASE:-http://127.0.0.1:18097/api/v1/analysis}"
|
||||
echo "== health =="
|
||||
curl -sS "$BASE/health" | tee /tmp/eba-smoke-health.json
|
||||
echo
|
||||
CREATE=$(curl -sS -X POST "$BASE/jobs" -H 'Content-Type: application/json' \
|
||||
-d '{"taskId":"smoke-'"$(date +%s)"'","title":"smoke","manualIntervention":true,"agents":[{"id":"a1","name":"甲"},{"id":"a2","name":"乙"}]}')
|
||||
JOB=$(echo "$CREATE" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["id"])')
|
||||
echo "job=$JOB"
|
||||
for i in $(seq 1 40); do
|
||||
J=$(curl -sS "$BASE/jobs/$JOB")
|
||||
ST=$(echo "$J" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
|
||||
PCT=$(echo "$J" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"].get("progress",{}).get("percent",0))')
|
||||
echo "tick=$i status=$ST percent=$PCT"
|
||||
[ "$ST" = "done" ] && break
|
||||
sleep 0.5
|
||||
done
|
||||
echo OK
|
||||
Reference in new issue
Block a user