Go 实现 /api/v1/analysis 契约、Demo/Live Worker、DeerFlow SSE;含设计文档与冒烟脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
353 lines
10 KiB
Go
353 lines
10 KiB
Go
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)
|
||
}
|