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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user