refactor(boundaries): split five oversized packages by use-case
Move bizdev/freedoc/intent/projectworkflow/squad giant files into ≤800-line use-case siblings without behavior change; short tests green.
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
title: "模块解耦残余重构(巨型文件 + W4 + Sysadmin 二期)"
|
||||||
|
task_id: modular-decouple-residual-2026-09-23
|
||||||
|
status: in_progress
|
||||||
|
created: 2026-09-23
|
||||||
|
updated: 2026-09-23
|
||||||
|
owner: Cursor
|
||||||
|
task_type: implementation
|
||||||
|
task_tier: multi_stage
|
||||||
|
policy_revision: "AGENTS+module-dev-standards+validation-contract @ 2fae99096568a2941ea38c3fd1a38c762e455027"
|
||||||
|
branch: feature/modular-decouple-residual-2026-09-23
|
||||||
|
worktree: .worktrees/modular-decouple-residual-2026-09-23
|
||||||
|
base_dev_sha: "2fae99096568a2941ea38c3fd1a38c762e455027"
|
||||||
|
write_scope: ["internal", "scripts", "web", ".doc"]
|
||||||
|
allowed_environments: []
|
||||||
|
head_sha: ""
|
||||||
|
mutex: "modular-decouple-residual"
|
||||||
|
review_state: pending
|
||||||
|
integration_state: pending
|
||||||
|
deployment: not_required
|
||||||
|
cleanup: pending
|
||||||
|
cleanup_owner: "独立维护者"
|
||||||
|
cleanup_trigger: "全部批次合入 local_dev 且无进程占用后 remove"
|
||||||
|
remote_sync: not_requested
|
||||||
|
---
|
||||||
|
|
||||||
|
# 模块解耦残余重构
|
||||||
|
|
||||||
|
## 用户目标与授权
|
||||||
|
|
||||||
|
- 用户目标:按计划完成巨型文件拆分(9 批)+ W4 治理收口 + Sysadmin 二期;门禁+合入本机 `dev`;不 push;不触红线。
|
||||||
|
- 授权来源:用户确认「模块解耦残余重构实施计划」(全部残余 / 门禁+合入)。
|
||||||
|
- 禁止:FAV live、SWBOOT-10、D7 物理删、PI 叙事 flag、tier 升级、语义摘要接线;不改行为的顺手修 bug。
|
||||||
|
|
||||||
|
## 分批清单
|
||||||
|
|
||||||
|
| 批 | 目标 | 状态 | tip |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A | `bizdev/service.go` 拆分 | pending | |
|
||||||
|
| B | `projectinstance/store.go` | pending | |
|
||||||
|
| C | `intent/recognizer.go` | pending | |
|
||||||
|
| D | `server/handlers.go` | pending | |
|
||||||
|
| E | `projectworkflow/store.go` | pending | |
|
||||||
|
| F | `docgov/engine.go` | pending | |
|
||||||
|
| G | `docrevision/service.go` | pending | |
|
||||||
|
| H | `squad/workspace.go` | pending | |
|
||||||
|
| I | `freedoc/service.go` | pending | |
|
||||||
|
| W4 | projection + deploy/recover + Darwin tests | pending | |
|
||||||
|
| Sysadmin | `system:*` + 设置审计 | pending | |
|
||||||
|
|
||||||
|
## 每批验收
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/arch-check.sh --mode block
|
||||||
|
go vet ./...
|
||||||
|
go test -short ./internal/<pkg>/...
|
||||||
|
```
|
||||||
|
|
||||||
|
新生产文件 ≤800 行;无新增跨模块 import。
|
||||||
|
|
||||||
|
## 相关链接
|
||||||
|
|
||||||
|
- 计划:用户确认的模块解耦残余重构实施计划
|
||||||
|
- 承接框架:../remediation/2026-09-23-modular-refactor-implementation-undertaking-plan.md
|
||||||
|
- 原 W4:./2026-09-22-governance-automation.md(并入本任务,旧基线不续写)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
package bizdev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/docduty"
|
||||||
|
"chat-agent-system/internal/docgov"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resolveTaskDocumentDuties(ctx context.Context, pool *pgxpool.Pool, req TaskRunRequest, instanceDuty InstanceDutySource) (docduty.Binding, string, bool) {
|
||||||
|
if instanceDuty != nil && req.ProjectID > 0 && strings.TrimSpace(req.TaskKey) != "" {
|
||||||
|
authorID, reviewerID, approverID, dutyPolicy, governed, err := instanceDuty.ResolveGovernedDuty(ctx, req.ProjectID, req.TaskKey)
|
||||||
|
if err == nil && governed {
|
||||||
|
bound := docduty.Binding{
|
||||||
|
Author: docduty.Holder{UserID: authorID},
|
||||||
|
Reviewer: docduty.Holder{UserID: reviewerID},
|
||||||
|
Approver: docduty.Holder{UserID: approverID},
|
||||||
|
}
|
||||||
|
if pool != nil {
|
||||||
|
if members, creator, loadErr := docduty.LoadProjectMembers(ctx, pool, req.ProjectID); loadErr == nil {
|
||||||
|
bound.Author.Name = holderName(members, creator, authorID, req.AuthorName)
|
||||||
|
bound.Reviewer.Name = holderName(members, creator, reviewerID, req.ReviewerName)
|
||||||
|
bound.Approver.Name = holderName(members, creator, approverID, req.ApproverName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bound.Author.Name == "" {
|
||||||
|
bound.Author.Name = strings.TrimSpace(req.AuthorName)
|
||||||
|
}
|
||||||
|
if bound.Reviewer.Name == "" {
|
||||||
|
bound.Reviewer.Name = strings.TrimSpace(req.ReviewerName)
|
||||||
|
}
|
||||||
|
if bound.Approver.Name == "" {
|
||||||
|
bound.Approver.Name = strings.TrimSpace(req.ApproverName)
|
||||||
|
}
|
||||||
|
return bound, dutyPolicy, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reqDuties := docduty.RequestDuties{
|
||||||
|
AuthorID: req.AuthorID, AuthorName: req.AuthorName,
|
||||||
|
ReviewerID: req.ReviewerID, ReviewerName: req.ReviewerName,
|
||||||
|
ApproverID: req.ApproverID, ApproverName: req.ApproverName,
|
||||||
|
}
|
||||||
|
if reqDuties.AuthorID == 0 {
|
||||||
|
reqDuties.AuthorID = req.UserID
|
||||||
|
}
|
||||||
|
if pool != nil && req.ProjectID > 0 {
|
||||||
|
if bound, err := docduty.ResolveProject(ctx, pool, req.ProjectID, reqDuties); err == nil {
|
||||||
|
if bound.Author.UserID == 0 && req.UserID > 0 {
|
||||||
|
bound.Author = docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)}
|
||||||
|
}
|
||||||
|
return bound, "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
creator := docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)}
|
||||||
|
return docduty.Resolve(nil, creator, reqDuties), "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func holderName(members []docduty.Member, creator docduty.Holder, userID int64, fallback string) string {
|
||||||
|
if userID <= 0 {
|
||||||
|
return strings.TrimSpace(fallback)
|
||||||
|
}
|
||||||
|
if creator.UserID == userID && strings.TrimSpace(creator.Name) != "" {
|
||||||
|
return strings.TrimSpace(creator.Name)
|
||||||
|
}
|
||||||
|
for _, m := range members {
|
||||||
|
if m.UserID == userID && strings.TrimSpace(m.Name) != "" {
|
||||||
|
return strings.TrimSpace(m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineageVersion(doc *docgov.DocumentRecord) int {
|
||||||
|
if doc != nil && doc.Version > 0 {
|
||||||
|
return doc.Version
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindingFromDoc(doc *docgov.DocumentRecord) docduty.Binding {
|
||||||
|
if doc == nil {
|
||||||
|
return docduty.Binding{}
|
||||||
|
}
|
||||||
|
return docduty.Binding{
|
||||||
|
Author: docduty.Holder{UserID: doc.AuthorID, Name: doc.AuthorName},
|
||||||
|
Reviewer: docduty.Holder{UserID: doc.ReviewerID, Name: doc.ReviewerName},
|
||||||
|
Approver: docduty.Holder{UserID: doc.ApproverID, Name: doc.ApproverName},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBindingToDoc(doc *docgov.DocumentRecord, bound docduty.Binding) {
|
||||||
|
if doc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc.AuthorID, doc.AuthorName = bound.Author.UserID, bound.Author.Name
|
||||||
|
doc.ReviewerID, doc.ReviewerName = bound.Reviewer.UserID, bound.Reviewer.Name
|
||||||
|
doc.ApproverID, doc.ApproverName = bound.Approver.UserID, bound.Approver.Name
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package bizdev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/squad"
|
||||||
|
"chat-agent-system/internal/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) ExecuteStage(ctx context.Context, req StageRunRequest) (*StageRunResult, error) {
|
||||||
|
return s.executeStageInternal(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) (*StageRunResult, error) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, string(req.Stage)); err != nil {
|
||||||
|
return &StageRunResult{
|
||||||
|
Stage: req.Stage,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 确定工作区目录基准
|
||||||
|
stageKey := string(req.Stage)
|
||||||
|
if stageKey == "" {
|
||||||
|
stageKey = string(StageV01_Triage)
|
||||||
|
req.Stage = StageV01_Triage
|
||||||
|
}
|
||||||
|
|
||||||
|
workDir := req.WorkDir
|
||||||
|
if workDir == "" {
|
||||||
|
workDir = filepath.Join(s.cfg.DataDir, "workspaces", "bizdev", fmt.Sprintf("stage_%s_%d", stageKey, time.Now().UnixNano()))
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(workDir, 0o755)
|
||||||
|
|
||||||
|
// 2. 匹配阶段元数据与任务序列
|
||||||
|
var targetMeta *StageMeta
|
||||||
|
for _, sm := range SupportedStages {
|
||||||
|
if sm.Stage == req.Stage {
|
||||||
|
targetMeta = &sm
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetMeta == nil {
|
||||||
|
targetMeta = &SupportedStages[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
streamer := stream.FromContext(ctx)
|
||||||
|
if streamer != nil {
|
||||||
|
streamer.EmitPhase("stage_started", fmt.Sprintf("开始推进商务成熟度阶段【%s】(包含 %d 项任务序列)...", req.Stage, len(targetMeta.Tasks)), "coordinator", 0, len(targetMeta.Tasks), 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 汇总当前阶段累积的事实与前序文档
|
||||||
|
currentFields := make(map[string]string)
|
||||||
|
|
||||||
|
// 若存在项目协同管理器,优先载入已沉淀的项目事实与黑板
|
||||||
|
if s.ctxMgr != nil && req.ProjectID > 0 {
|
||||||
|
if projCtx, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && projCtx != nil {
|
||||||
|
for k, v := range projCtx.BlackboardFacts {
|
||||||
|
currentFields[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Fields != nil {
|
||||||
|
for k, v := range req.Fields {
|
||||||
|
currentFields[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.ProjectName != "" {
|
||||||
|
currentFields["project_name"] = req.ProjectName
|
||||||
|
}
|
||||||
|
if req.CustomerName != "" {
|
||||||
|
currentFields["customer_name"] = req.CustomerName
|
||||||
|
}
|
||||||
|
if req.DeviceType != "" {
|
||||||
|
currentFields["device_type"] = req.DeviceType
|
||||||
|
}
|
||||||
|
if req.IntendedUse != "" {
|
||||||
|
currentFields["intended_use"] = req.IntendedUse
|
||||||
|
}
|
||||||
|
if req.TargetMarket != "" {
|
||||||
|
currentFields["target_market"] = req.TargetMarket
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulatedPriorDocs := append([]squad.PriorDocItem{}, req.PriorDocs...)
|
||||||
|
if s.stageGate != nil && req.ProjectID > 0 && req.UserID > 0 {
|
||||||
|
accumulatedPriorDocs = s.stageGate.FilterPriorDocs(ctx, req.ProjectID, req.UserID, accumulatedPriorDocs)
|
||||||
|
}
|
||||||
|
var executedArtifacts []*TaskRunResult
|
||||||
|
|
||||||
|
// 4. 按顺序执行当前阶段的子任务序列,并将每一轮产出作为后续任务的 PriorDoc 和事实补充
|
||||||
|
for _, tKey := range targetMeta.Tasks {
|
||||||
|
subWorkDir := filepath.Join(workDir, tKey)
|
||||||
|
tReq := TaskRunRequest{
|
||||||
|
TaskKey: tKey,
|
||||||
|
ProjectName: req.ProjectName,
|
||||||
|
CustomerName: req.CustomerName,
|
||||||
|
DeviceType: req.DeviceType,
|
||||||
|
IntendedUse: req.IntendedUse,
|
||||||
|
TargetMarket: req.TargetMarket,
|
||||||
|
Fields: currentFields,
|
||||||
|
Materials: req.Materials,
|
||||||
|
Backend: req.Backend,
|
||||||
|
Timeout: req.Timeout,
|
||||||
|
WorkDir: subWorkDir,
|
||||||
|
AutoRepair: req.AutoRepair,
|
||||||
|
KnowledgeHits: req.KnowledgeHits,
|
||||||
|
PriorDocs: accumulatedPriorDocs,
|
||||||
|
SessionID: req.SessionID,
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
|
UserID: req.UserID,
|
||||||
|
RunID: req.RunID,
|
||||||
|
AuthorID: req.AuthorID,
|
||||||
|
AuthorName: req.AuthorName,
|
||||||
|
ReviewerID: req.ReviewerID,
|
||||||
|
ReviewerName: req.ReviewerName,
|
||||||
|
ApproverID: req.ApproverID,
|
||||||
|
ApproverName: req.ApproverName,
|
||||||
|
ForceDirectEdit: req.ForceDirectEdit,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.executeStageTask(ctx, tReq)
|
||||||
|
if err != nil {
|
||||||
|
return &StageRunResult{
|
||||||
|
Stage: req.Stage,
|
||||||
|
StageTitle: targetMeta.Title,
|
||||||
|
Success: false,
|
||||||
|
WorkDir: workDir,
|
||||||
|
Duration: time.Since(start),
|
||||||
|
ErrorReason: fmt.Sprintf("task %s failed: %v", tKey, err),
|
||||||
|
SubArtifacts: executedArtifacts,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if halt := classifyStageTaskHalt(res, tKey); halt != nil {
|
||||||
|
out := &StageRunResult{
|
||||||
|
Stage: req.Stage,
|
||||||
|
StageTitle: targetMeta.Title,
|
||||||
|
Success: false,
|
||||||
|
WorkDir: workDir,
|
||||||
|
Duration: time.Since(start),
|
||||||
|
ErrorReason: halt.reason,
|
||||||
|
PrimaryArtifact: res,
|
||||||
|
SubArtifacts: append(executedArtifacts, res),
|
||||||
|
}
|
||||||
|
if halt.pending {
|
||||||
|
out.PendingAgentCandidateID = res.PendingAgentCandidateID
|
||||||
|
out.PendingChangeReportID = res.PendingChangeReportID
|
||||||
|
out.PendingRiskLevel = res.PendingRiskLevel
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
executedArtifacts = append(executedArtifacts, res)
|
||||||
|
|
||||||
|
// 吸收回填事实(仅已提交且过门禁的产物)
|
||||||
|
if res.ExtractedData != nil {
|
||||||
|
for k, v := range res.ExtractedData {
|
||||||
|
if v != "" {
|
||||||
|
currentFields[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加前序文档供同阶段后续任务使用
|
||||||
|
accumulatedPriorDocs = append(accumulatedPriorDocs, squad.PriorDocItem{
|
||||||
|
DocKey: tKey,
|
||||||
|
Title: res.ArtifactTitle,
|
||||||
|
Filename: fmt.Sprintf("%s.md", tKey),
|
||||||
|
Content: res.MarkdownBody,
|
||||||
|
Stage: string(req.Stage),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 确定主交付物并组装阶段级参谋简报
|
||||||
|
var primaryArtifact *TaskRunResult
|
||||||
|
if len(executedArtifacts) > 0 {
|
||||||
|
primaryArtifact = executedArtifacts[len(executedArtifacts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
stageBrief := primaryArtifact.AdvisorBrief
|
||||||
|
if stageBrief != nil {
|
||||||
|
// 覆盖校准为当前阶段等级
|
||||||
|
switch req.Stage {
|
||||||
|
case StageV01_Triage:
|
||||||
|
stageBrief.MaturityLevel = "V0.1"
|
||||||
|
case StageV05_Feasibility:
|
||||||
|
stageBrief.MaturityLevel = "V0.5"
|
||||||
|
case StageV10_Quoting:
|
||||||
|
stageBrief.MaturityLevel = "V1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化阶段级元数据与事实黑板,便于 L2 上下文装载与对话系统读取
|
||||||
|
stageMetaRecord := squad.WorkspaceMetadata{
|
||||||
|
ProjectName: req.ProjectName,
|
||||||
|
CustomerName: req.CustomerName,
|
||||||
|
DeviceType: req.DeviceType,
|
||||||
|
IntendedUse: req.IntendedUse,
|
||||||
|
TargetMarket: req.TargetMarket,
|
||||||
|
TaskKey: string(req.Stage),
|
||||||
|
Status: "completed",
|
||||||
|
CreatedAt: start,
|
||||||
|
}
|
||||||
|
if b, err := json.MarshalIndent(stageMetaRecord, "", " "); err == nil {
|
||||||
|
_ = os.WriteFile(filepath.Join(workDir, "workspace.json"), b, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
bbRecord := squad.BlackboardState{
|
||||||
|
Fields: currentFields,
|
||||||
|
}
|
||||||
|
if b, err := json.MarshalIndent(bbRecord, "", " "); err == nil {
|
||||||
|
_ = os.WriteFile(filepath.Join(workDir, "blackboard.json"), b, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stageBrief != nil {
|
||||||
|
if b, err := json.MarshalIndent(stageBrief, "", " "); err == nil {
|
||||||
|
_ = os.WriteFile(filepath.Join(workDir, "advisor_brief.json"), b, 0o644)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将本阶段累积沉淀的事实回填到 ContextManager,供下一阶段或后续对话读取
|
||||||
|
if s.ctxMgr != nil && req.ProjectID > 0 {
|
||||||
|
_ = s.ctxMgr.UpdateProjectBlackboard(ctx, req.ProjectID, currentFields)
|
||||||
|
_ = s.ctxMgr.UpdateProjectStage(ctx, req.ProjectID, string(req.Stage))
|
||||||
|
authorName := req.AuthorName
|
||||||
|
if authorName == "" {
|
||||||
|
authorName = "系统协调员"
|
||||||
|
}
|
||||||
|
_, _ = s.ctxMgr.RecordActivity(
|
||||||
|
ctx,
|
||||||
|
req.ProjectID,
|
||||||
|
req.UserID,
|
||||||
|
authorName,
|
||||||
|
"stage_advance",
|
||||||
|
fmt.Sprintf("阶段跃迁至【%s】", targetMeta.Title),
|
||||||
|
map[string]any{
|
||||||
|
"stage": string(req.Stage),
|
||||||
|
"duration_ms": time.Since(start).Milliseconds(),
|
||||||
|
"artifacts": len(executedArtifacts),
|
||||||
|
"device_type": req.DeviceType,
|
||||||
|
"target_market": req.TargetMarket,
|
||||||
|
},
|
||||||
|
1.0,
|
||||||
|
true, // 里程碑决策,豁免衰减
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if streamer != nil {
|
||||||
|
streamer.EmitPhase("stage_completed", fmt.Sprintf("商务成熟度阶段【%s】全部任务协同推演完成!", req.Stage), "coordinator", len(targetMeta.Tasks), len(targetMeta.Tasks), 100)
|
||||||
|
if stageBrief != nil {
|
||||||
|
streamer.EmitSummary(targetMeta.Title+"完成", stageBrief.MaturityHeadline, stageBrief.MaturityScore, stageBrief.MaturityLevel)
|
||||||
|
streamer.EmitBriefing(stageBrief)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StageRunResult{
|
||||||
|
Stage: req.Stage,
|
||||||
|
StageTitle: targetMeta.Title,
|
||||||
|
PrimaryArtifact: primaryArtifact,
|
||||||
|
SubArtifacts: executedArtifacts,
|
||||||
|
AdvisorBrief: stageBrief,
|
||||||
|
Duration: time.Since(start),
|
||||||
|
Success: true,
|
||||||
|
WorkDir: workDir,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectWorkspace 查看指定工作区的当前执行状态、黑板数据与各专员产出
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package bizdev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/docgov"
|
||||||
|
"chat-agent-system/internal/docrevision"
|
||||||
|
"chat-agent-system/internal/projectworkflow"
|
||||||
|
"chat-agent-system/internal/squad"
|
||||||
|
"chat-agent-system/internal/workflowdef"
|
||||||
|
)
|
||||||
|
|
||||||
|
func qualityBlocksControlledProjection(report *squad.QualityReport) bool {
|
||||||
|
return report != nil && !report.Passed
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoRepairForControlledSquad 已解析出正式受控文档后强制开启修复;非受控调用仍尊重请求值。
|
||||||
|
func autoRepairForControlledSquad(reqAutoRepair bool, docID string, docRec *docgov.DocumentRecord) bool {
|
||||||
|
if strings.TrimSpace(docID) != "" && docRec != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return reqAutoRepair
|
||||||
|
}
|
||||||
|
|
||||||
|
func qualityFailedErrorReason(report *squad.QualityReport) string {
|
||||||
|
if report == nil {
|
||||||
|
return "质量门禁未通过"
|
||||||
|
}
|
||||||
|
if len(report.Violations) > 0 {
|
||||||
|
return "质量门禁未通过:" + strings.Join(report.Violations, ";")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("质量门禁未通过(评分 %.0f/100)", report.Score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalizeSquadDeliverable 处理小队返回后的受控文档投影边界。
|
||||||
|
// 质量失败必须在 SetDocumentWorkDir / Materialize / MarkDocumentContentUpdated / 完成活动之前短路。
|
||||||
|
func (s *Service) finalizeSquadDeliverable(
|
||||||
|
ctx context.Context,
|
||||||
|
req TaskRunRequest,
|
||||||
|
res *squad.SquadRunResult,
|
||||||
|
docID string,
|
||||||
|
docRec *docgov.DocumentRecord,
|
||||||
|
appliedProposalID string,
|
||||||
|
upstreamSRDoc *docgov.DocumentRecord,
|
||||||
|
) (*TaskRunResult, error) {
|
||||||
|
if res == nil {
|
||||||
|
return &TaskRunResult{TaskKey: req.TaskKey, Success: false, ErrorReason: "小队无返回结果"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 质量门禁短路:保留隔离工作区与可见失败摘要,不改正式 doc work_dir/head/hash,不激活账本版本。
|
||||||
|
if qualityBlocksControlledProjection(res.QualityReport) {
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey,
|
||||||
|
ArtifactTitle: res.ArtifactTitle,
|
||||||
|
MarkdownBody: res.MarkdownBody,
|
||||||
|
ExtractedData: res.ExtractedData,
|
||||||
|
Attachments: res.Attachments,
|
||||||
|
WorkDir: res.WorkDir,
|
||||||
|
BackendUsed: res.BackendUsed,
|
||||||
|
Duration: res.Duration,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: qualityFailedErrorReason(res.QualityReport),
|
||||||
|
QualityReport: res.QualityReport,
|
||||||
|
Manifest: res.Manifest,
|
||||||
|
AdvisorBrief: res.AdvisorBrief,
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordActivity for "完成交付物" must NOT run here: Materialize may still fail or
|
||||||
|
// leave needs_confirmation. Activity is recorded only after a committed projection
|
||||||
|
// (or a pending-awaiting note that is explicitly not completion).
|
||||||
|
|
||||||
|
if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil {
|
||||||
|
_ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir)
|
||||||
|
dataRoot := "."
|
||||||
|
if s.cfg != nil && s.cfg.DataDir != "" {
|
||||||
|
dataRoot = s.cfg.DataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOCREV: materialize first; only committed heads may update DocGov hash / proposal applied.
|
||||||
|
// Squad may have already written candidate body to workDir/output.md — pending must restore ledger head.
|
||||||
|
if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" {
|
||||||
|
actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed}
|
||||||
|
mat, matErr := s.materializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
|
||||||
|
DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey),
|
||||||
|
ProposalID: appliedProposalID, OutputMarkdown: res.MarkdownBody,
|
||||||
|
ProjectID: req.ProjectID, WorkDir: res.WorkDir,
|
||||||
|
ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: actor,
|
||||||
|
AutoCommitUserID: req.UserID,
|
||||||
|
})
|
||||||
|
if matErr != nil {
|
||||||
|
reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey,
|
||||||
|
ArtifactTitle: res.ArtifactTitle,
|
||||||
|
MarkdownBody: res.MarkdownBody,
|
||||||
|
ExtractedData: res.ExtractedData,
|
||||||
|
Attachments: res.Attachments,
|
||||||
|
WorkDir: res.WorkDir,
|
||||||
|
BackendUsed: res.BackendUsed,
|
||||||
|
Duration: res.Duration,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: reason,
|
||||||
|
QualityReport: res.QualityReport,
|
||||||
|
Manifest: res.Manifest,
|
||||||
|
AdvisorBrief: res.AdvisorBrief,
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, matErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if mat != nil && mat.Candidate != nil && mat.Candidate.Status == docrevision.CandidateNeedsConfirmation {
|
||||||
|
if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil {
|
||||||
|
reason := fmt.Sprintf("高风险待确认:恢复账本 head 到事实源失败: %v", err)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||||
|
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
|
||||||
|
Success: false, ErrorReason: reason, DocRecord: docRec,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
headSHA := ""
|
||||||
|
if mat.State != nil {
|
||||||
|
headSHA = mat.State.HeadSHA256
|
||||||
|
}
|
||||||
|
if headSHA != "" {
|
||||||
|
if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil {
|
||||||
|
reason := fmt.Sprintf("高风险待确认:恢复 document_records 哈希失败: %v", err)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||||
|
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
|
||||||
|
Success: false, ErrorReason: reason, DocRecord: docRec,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
|
||||||
|
docRec = latest
|
||||||
|
}
|
||||||
|
risk := ""
|
||||||
|
if mat.Report != nil {
|
||||||
|
risk = mat.Report.RiskLevel
|
||||||
|
}
|
||||||
|
var pendCand, pendRep int64
|
||||||
|
if mat.Candidate != nil {
|
||||||
|
pendCand = mat.Candidate.ID
|
||||||
|
if mat.Candidate.ChangeReportID != nil {
|
||||||
|
pendRep = *mat.Candidate.ChangeReportID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
note := fmt.Sprintf(
|
||||||
|
"高风险变更(%s)待编制人确认:候选 #%d。正式正文仍为当前账本 head,候选正文仅保留在不可变快照中;请打开「待确认报告」确认提交或拒绝。",
|
||||||
|
risk, pendCand,
|
||||||
|
)
|
||||||
|
s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle,
|
||||||
|
fmt.Sprintf("交付物【%s】候选待编制人确认", res.ArtifactTitle),
|
||||||
|
map[string]any{
|
||||||
|
"task_key": res.TaskKey,
|
||||||
|
"work_dir": res.WorkDir,
|
||||||
|
"doc_id": docID,
|
||||||
|
"pending_agent_candidate_id": pendCand,
|
||||||
|
"pending_change_report_id": pendRep,
|
||||||
|
"pending_risk_level": risk,
|
||||||
|
"awaiting_author_confirmation": true,
|
||||||
|
}, true)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||||
|
ExtractedData: res.ExtractedData, Attachments: res.Attachments, WorkDir: res.WorkDir,
|
||||||
|
BackendUsed: res.BackendUsed, Duration: res.Duration, Success: true,
|
||||||
|
QualityReport: res.QualityReport, Manifest: res.Manifest, AdvisorBrief: res.AdvisorBrief,
|
||||||
|
DocRecord: docRec,
|
||||||
|
// RequiresAuthorApproval signals "needs human decision"; distinguish from DocGov
|
||||||
|
// proposal intercept via PendingAgentCandidateID (no PendingProposal).
|
||||||
|
RequiresAuthorApproval: true,
|
||||||
|
PendingAgentCandidateID: pendCand, PendingChangeReportID: pendRep,
|
||||||
|
PendingRiskLevel: risk, PendingConfirmationNote: note,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Committed (auto-commit or ready path): project head then update DocGov.
|
||||||
|
if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil {
|
||||||
|
reason := fmt.Sprintf("账本已提交但工作区投影失败: %v", err)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||||
|
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
|
||||||
|
Success: false, ErrorReason: reason, DocRecord: docRec,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
headSHA := ""
|
||||||
|
if mat != nil && mat.State != nil {
|
||||||
|
headSHA = mat.State.HeadSHA256
|
||||||
|
}
|
||||||
|
if headSHA == "" {
|
||||||
|
sha := sha256.Sum256([]byte(res.MarkdownBody))
|
||||||
|
headSHA = hex.EncodeToString(sha[:])
|
||||||
|
}
|
||||||
|
if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil {
|
||||||
|
reason := fmt.Sprintf("账本已提交但 DocGov 哈希投影失败: %v", err)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||||
|
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
|
||||||
|
Success: false, ErrorReason: reason, DocRecord: docRec,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
if appliedProposalID != "" {
|
||||||
|
if err := s.docGov.MarkProposalApplied(ctx, appliedProposalID); err != nil {
|
||||||
|
reason := fmt.Sprintf("账本已提交但变更提案标记失败(可重试): %v", err)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||||
|
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
|
||||||
|
Success: false, ErrorReason: reason, DocRecord: docRec,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
|
||||||
|
docRec = latest
|
||||||
|
}
|
||||||
|
s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle,
|
||||||
|
fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle),
|
||||||
|
map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir, "doc_id": docID}, true)
|
||||||
|
} else {
|
||||||
|
// No docrevision: preserve prior best-effort hash update (CLI/legacy).
|
||||||
|
sha := sha256.Sum256([]byte(res.MarkdownBody))
|
||||||
|
shaStr := hex.EncodeToString(sha[:])
|
||||||
|
_ = s.docGov.MarkDocumentContentUpdated(ctx, docID, shaStr, false)
|
||||||
|
if appliedProposalID != "" {
|
||||||
|
if err := s.docGov.MarkProposalApplied(ctx, appliedProposalID); err != nil {
|
||||||
|
reason := fmt.Sprintf("正文已更新但变更提案标记失败(可重试): %v", err)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
|
||||||
|
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
|
||||||
|
Success: false, ErrorReason: reason, DocRecord: docRec,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
|
||||||
|
docRec = latest
|
||||||
|
}
|
||||||
|
s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle,
|
||||||
|
fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle),
|
||||||
|
map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir, "doc_id": docID}, true)
|
||||||
|
}
|
||||||
|
} else if res.Success {
|
||||||
|
// Uncontrolled / no DocGov path: squad success is the completion boundary.
|
||||||
|
s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle,
|
||||||
|
fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle),
|
||||||
|
map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Success && workflowdef.SameTaskKey(req.TaskKey, "test_manager") && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 {
|
||||||
|
_ = s.projectWorkflow.RecordLineage(ctx, &projectworkflow.DocumentLineage{
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
|
DownstreamDocID: docID,
|
||||||
|
DownstreamVersion: lineageVersion(docRec),
|
||||||
|
UpstreamDocID: upstreamSRDoc.DocID,
|
||||||
|
UpstreamVersion: lineageVersion(upstreamSRDoc),
|
||||||
|
UpstreamSHA256: upstreamSRDoc.ContentSHA256,
|
||||||
|
IsStale: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Stale propagation only after fact source committed (not while pending confirmation).
|
||||||
|
if res.Success && workflowdef.SameTaskKey(req.TaskKey, "system_reqs") && s.projectWorkflow != nil && req.ProjectID > 0 &&
|
||||||
|
(s.docRev == nil || (docRec != nil && docRec.ContentSHA256 != "")) {
|
||||||
|
sha := sha256.Sum256([]byte(res.MarkdownBody))
|
||||||
|
shaStr := hex.EncodeToString(sha[:])
|
||||||
|
if docRec != nil && docRec.ContentSHA256 != "" {
|
||||||
|
shaStr = docRec.ContentSHA256
|
||||||
|
}
|
||||||
|
_, _ = s.projectWorkflow.CheckAndPropagateStale(ctx, docID, shaStr, "上游系统需求规格书已更新,下游验证方案需复核")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey,
|
||||||
|
ArtifactTitle: res.ArtifactTitle,
|
||||||
|
MarkdownBody: res.MarkdownBody,
|
||||||
|
ExtractedData: res.ExtractedData,
|
||||||
|
Attachments: res.Attachments,
|
||||||
|
WorkDir: res.WorkDir,
|
||||||
|
BackendUsed: res.BackendUsed,
|
||||||
|
Duration: res.Duration,
|
||||||
|
Success: res.Success,
|
||||||
|
ErrorReason: res.ErrorReason,
|
||||||
|
QualityReport: res.QualityReport,
|
||||||
|
Manifest: res.Manifest,
|
||||||
|
AdvisorBrief: res.AdvisorBrief,
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSquadDeliveryActivity writes project activity only for explicit outcomes.
|
||||||
|
// Callers must not invoke this for Materialize/projection failures.
|
||||||
|
func (s *Service) recordSquadDeliveryActivity(ctx context.Context, req TaskRunRequest, artifactTitle, title string, detail map[string]any, milestone bool) {
|
||||||
|
if s == nil || s.ctxMgr == nil || req.ProjectID <= 0 || strings.TrimSpace(title) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if detail == nil {
|
||||||
|
detail = map[string]any{}
|
||||||
|
}
|
||||||
|
if _, ok := detail["task_key"]; !ok && req.TaskKey != "" {
|
||||||
|
detail["task_key"] = req.TaskKey
|
||||||
|
}
|
||||||
|
if artifactTitle != "" {
|
||||||
|
detail["artifact_title"] = artifactTitle
|
||||||
|
}
|
||||||
|
_, _ = s.ctxMgr.RecordActivity(ctx, req.ProjectID, req.UserID, "系统智能体小队", "operation",
|
||||||
|
title, detail, 1.0, milestone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteStage 执行指定成熟度阶段
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
package bizdev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/contracts/vocabulary"
|
||||||
|
"chat-agent-system/internal/docduty"
|
||||||
|
"chat-agent-system/internal/docgov"
|
||||||
|
"chat-agent-system/internal/docrevision"
|
||||||
|
"chat-agent-system/internal/kb"
|
||||||
|
"chat-agent-system/internal/projectcontext"
|
||||||
|
"chat-agent-system/internal/projectworkflow"
|
||||||
|
"chat-agent-system/internal/squad"
|
||||||
|
"chat-agent-system/internal/stream"
|
||||||
|
"chat-agent-system/internal/workflowdef"
|
||||||
|
"chat-agent-system/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) materializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) {
|
||||||
|
if s != nil && s.materializer != nil {
|
||||||
|
return s.materializer.MaterializeAgentOutput(ctx, req)
|
||||||
|
}
|
||||||
|
if s == nil || s.docRev == nil {
|
||||||
|
return nil, fmt.Errorf("bizdev: document revision unavailable")
|
||||||
|
}
|
||||||
|
return s.docRev.MaterializeAgentOutput(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
var SupportedTasks = squad.SupportedTasks
|
||||||
|
|
||||||
|
var SupportedStages = []StageMeta{
|
||||||
|
{
|
||||||
|
Stage: StageV01_Triage,
|
||||||
|
Title: "V0.1 闪电摸底与盲区探测",
|
||||||
|
Description: "快速梳理客户诉求与约束,建立基准技术假设,生成首期澄清微信话术与初始需求表",
|
||||||
|
Tasks: []string{"bd_intake"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Stage: StageV05_Feasibility,
|
||||||
|
Title: "V0.5 方案成型与法规对齐",
|
||||||
|
Description: "法规与注册路径初筛、竞品调研、系统初步架构设计与关键选型建议",
|
||||||
|
Tasks: []string{"bd_research", "bd_feasibility_dev", "bd_feasibility_reg", "bd_user_requirements", "bd_prelim_design"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Stage: StageV10_Quoting,
|
||||||
|
Title: "V1.0 工程拆解与商业锁定",
|
||||||
|
Description: "全维度 WBS 工时拆解、Excel 定价核算、正式 CDO 商务报价单与谈判防御条款建议",
|
||||||
|
Tasks: []string{"bd_cdo_quote"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskIs(key string, wants ...string) bool {
|
||||||
|
for _, want := range wants {
|
||||||
|
if workflowdef.SameTaskKey(key, want) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDocStage(req TaskRunRequest) string {
|
||||||
|
if sk := strings.TrimSpace(req.StageKey); sk != "" {
|
||||||
|
return sk
|
||||||
|
}
|
||||||
|
switch workflowdef.ImplementationTaskKey(strings.TrimSpace(req.TaskKey)) {
|
||||||
|
case "intake":
|
||||||
|
return string(StageV01_Triage)
|
||||||
|
case "research", "feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design":
|
||||||
|
return string(StageV05_Feasibility)
|
||||||
|
case "cdo_quote":
|
||||||
|
return string(StageV10_Quoting)
|
||||||
|
default:
|
||||||
|
return string(StageV05_Feasibility)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rdevPrerequisiteMessage = "请先完成商务对接流程(CDO 报价批准)后再启动研发设计控制流程"
|
||||||
|
|
||||||
|
func (s *Service) ensureRDevMainCompleted(ctx context.Context, projectID int64) error {
|
||||||
|
if projectID <= 0 {
|
||||||
|
return fmt.Errorf("%w: %s", projectworkflow.ErrPrerequisiteNotMet, rdevPrerequisiteMessage)
|
||||||
|
}
|
||||||
|
ok, err := s.hasCompletedMainInstance(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: %s", projectworkflow.ErrPrerequisiteNotMet, rdevPrerequisiteMessage)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) hasCompletedMainInstance(ctx context.Context, projectID int64) (bool, error) {
|
||||||
|
if s.completedMainHook != nil {
|
||||||
|
return s.completedMainHook(ctx, projectID)
|
||||||
|
}
|
||||||
|
if s.pool == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM project_workflow_instances
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND status = 'completed'
|
||||||
|
AND config_snapshot->>'workflow_definition_key' = 'main'
|
||||||
|
)
|
||||||
|
`, projectID).Scan(&ok)
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) {
|
||||||
|
if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, req.TaskKey); err != nil {
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(req.TaskKey), "rd_") {
|
||||||
|
if err := s.ensureRDevMainCompleted(ctx, req.ProjectID); err != nil {
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
materials, inputManifest, err := s.ResolveMaterials(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.RunID != "" && inputManifest != nil {
|
||||||
|
inputManifest["run_id"] = req.RunID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 针对 system_reqs(智能产品经理):若未显式提供 materials,自动抽取项目事实黑板/集体上下文为需求输入
|
||||||
|
if workflowdef.SameTaskKey(req.TaskKey, "system_reqs") && len(materials) == 0 && s.ctxMgr != nil && req.ProjectID > 0 {
|
||||||
|
coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID)
|
||||||
|
if err == nil && coll != nil {
|
||||||
|
var b strings.Builder
|
||||||
|
customer := coll.BlackboardFacts["customer_name"]
|
||||||
|
if customer == "" {
|
||||||
|
customer = req.CustomerName
|
||||||
|
}
|
||||||
|
device := coll.BlackboardFacts["device_type"]
|
||||||
|
if device == "" {
|
||||||
|
device = req.DeviceType
|
||||||
|
}
|
||||||
|
intendedUse := coll.BlackboardFacts["intended_use"]
|
||||||
|
if intendedUse == "" {
|
||||||
|
intendedUse = req.IntendedUse
|
||||||
|
}
|
||||||
|
targetMarket := coll.BlackboardFacts["target_market"]
|
||||||
|
if targetMarket == "" {
|
||||||
|
targetMarket = req.TargetMarket
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf("# 项目需求背景事实与上下文输入\n\n- 项目名称: %s\n- 客户名称: %s\n- 产品类型: %s\n- 预期用途: %s\n- 目标市场: %s\n\n",
|
||||||
|
coll.ProjectName, customer, device, intendedUse, targetMarket))
|
||||||
|
|
||||||
|
if len(coll.BlackboardFacts) > 0 {
|
||||||
|
b.WriteString("## 已提取项目事实与技术参数\n")
|
||||||
|
for k, v := range coll.BlackboardFacts {
|
||||||
|
b.WriteString(fmt.Sprintf("- %s: %s\n", k, v))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if len(coll.MilestoneDecisions) > 0 {
|
||||||
|
b.WriteString("## 关键里程碑与团队决策\n")
|
||||||
|
for _, d := range coll.MilestoneDecisions {
|
||||||
|
b.WriteString(fmt.Sprintf("- [%s] %s: %s\n", d.UserName, d.ActivityType, d.Title))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
materials = append(materials, squad.MaterialItem{
|
||||||
|
Filename: "project_requirements_context.md",
|
||||||
|
Content: []byte(b.String()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动补充知识库检索 (如针对 research、feasibility_reg、cleanroom_spec、prelim_design、system_reqs、test_manager 等阶段)
|
||||||
|
hits := req.KnowledgeHits
|
||||||
|
if len(hits) == 0 && taskIs(req.TaskKey, "research", "feasibility_reg", "cleanroom_spec", "prelim_design", "system_reqs", "test_manager") {
|
||||||
|
device := req.DeviceType
|
||||||
|
if device == "" && req.Fields != nil {
|
||||||
|
device = req.Fields["device_type"]
|
||||||
|
}
|
||||||
|
market := req.TargetMarket
|
||||||
|
if market == "" && req.Fields != nil {
|
||||||
|
market = req.Fields["target_market"]
|
||||||
|
}
|
||||||
|
searchQuery := strings.TrimSpace(req.ProjectName + " " + device + " " + market)
|
||||||
|
if searchQuery != "" {
|
||||||
|
streamer := stream.FromContext(ctx)
|
||||||
|
if streamer != nil {
|
||||||
|
streamer.EmitPhase("knowledge_retrieval", fmt.Sprintf("项目助手正在检索权威法规知识库并筛选适用条款(关键词: %s)...", searchQuery), "regulatory", 0, 0, 10)
|
||||||
|
}
|
||||||
|
if kbHits, err := s.kb.Search(ctx, searchQuery, kb.SearchOptions{Limit: 5}); err == nil && len(kbHits) > 0 {
|
||||||
|
if streamer != nil {
|
||||||
|
streamer.EmitPhase("knowledge_retrieved", fmt.Sprintf("法规知识库初筛完成,精准命中 %d 条国家与行业标准条款", len(kbHits)), "regulatory", 0, 0, 15)
|
||||||
|
var hitLines []string
|
||||||
|
for _, h := range kbHits {
|
||||||
|
hitLines = append(hitLines, fmt.Sprintf("- [%s] %s (%s)", h.DocCode, h.Title, h.CiteKey))
|
||||||
|
}
|
||||||
|
streamer.EmitDetail("knowledge_hits", "knowledge", "regulatory", "命中法规标准清单", strings.Join(hitLines, "\n"), true)
|
||||||
|
}
|
||||||
|
for _, kh := range kbHits {
|
||||||
|
hits = append(hits, squad.KnowledgeHit{
|
||||||
|
DocCode: kh.DocCode,
|
||||||
|
Category: kh.Category,
|
||||||
|
Title: kh.Title,
|
||||||
|
HeadingPath: kh.HeadingPath,
|
||||||
|
Content: kh.Content,
|
||||||
|
CiteKey: kh.CiteKey,
|
||||||
|
Source: kh.Source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文档治理与认领拦截前置判定
|
||||||
|
artifactTitle := squad.TaskArtifactTitle(req.TaskKey)
|
||||||
|
docID, docErr := s.ResolveAndValidateDocID(ctx, req)
|
||||||
|
if docErr != nil {
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
ArtifactTitle: artifactTitle,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: docErr.Error(),
|
||||||
|
}, docErr
|
||||||
|
}
|
||||||
|
// 智能测试工程师:统一项目上下文快照;SRS 仅为可选高权威来源
|
||||||
|
var upstreamSRDoc *docgov.DocumentRecord
|
||||||
|
if taskIs(req.TaskKey, "test_manager", "system_reqs") {
|
||||||
|
priorDocs := make([]squad.PriorDocItem, 0)
|
||||||
|
facts := map[string]string{}
|
||||||
|
if req.Fields != nil {
|
||||||
|
for k, v := range req.Fields {
|
||||||
|
facts[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.ctxMgr != nil && req.ProjectID > 0 {
|
||||||
|
if coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && coll != nil {
|
||||||
|
for k, v := range coll.BlackboardFacts {
|
||||||
|
facts[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID)
|
||||||
|
if s.docGov != nil && req.ProjectID > 0 {
|
||||||
|
if doc, err := s.docGov.GetDocument(ctx, upstreamDocID); err == nil && doc != nil && doc.CurrentState == vocabulary.StateApproved {
|
||||||
|
upstreamSRDoc = doc
|
||||||
|
if doc.WorkDir != "" {
|
||||||
|
contentPath := filepath.Join(doc.WorkDir, workspace.DeliverableKey)
|
||||||
|
if b, readErr := os.ReadFile(contentPath); readErr == nil && len(b) > 0 {
|
||||||
|
priorDocs = append(priorDocs, squad.PriorDocItem{
|
||||||
|
DocKey: "system_reqs", Title: "系统需求规格书", Content: string(b), Stage: "approved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialogueInputs := s.collectSnapshotDialogueInputs(ctx, req.ProjectID, req.UserID)
|
||||||
|
ctxSvc := projectcontext.NewService(s.pool)
|
||||||
|
snap, _ := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{
|
||||||
|
ProjectID: req.ProjectID, TaskKey: req.TaskKey, UserID: req.UserID,
|
||||||
|
ProjectName: req.ProjectName, Facts: facts, Materials: materials, PriorDocs: priorDocs,
|
||||||
|
Knowledge: hits, Dialogue: dialogueInputs.Dialogue, Decisions: dialogueInputs.Decisions,
|
||||||
|
})
|
||||||
|
if snap != nil {
|
||||||
|
snap.ExcludedPrivateCount += dialogueInputs.ExcludedPrivate
|
||||||
|
snap.ExcludedNoAccessCount += dialogueInputs.ExcludedNoAccess
|
||||||
|
}
|
||||||
|
if snap != nil {
|
||||||
|
if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 {
|
||||||
|
materials = append(materials, extra...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 研发设计控制:项目上下文快照 + 同项目已批准文档摘要注入
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(req.TaskKey), "rd_") && req.ProjectID > 0 {
|
||||||
|
priorDocs := make([]squad.PriorDocItem, 0)
|
||||||
|
facts := map[string]string{}
|
||||||
|
if req.Fields != nil {
|
||||||
|
for k, v := range req.Fields {
|
||||||
|
facts[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.ctxMgr != nil {
|
||||||
|
if coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && coll != nil {
|
||||||
|
for k, v := range coll.BlackboardFacts {
|
||||||
|
facts[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 优先注入已批准 SR(PM 或 rd_system_reqs)
|
||||||
|
for _, key := range []string{"system_reqs", "pm_system_reqs", "rd_system_reqs"} {
|
||||||
|
docID := fmt.Sprintf("doc_proj%d_%s", req.ProjectID, key)
|
||||||
|
if s.docGov == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
doc, err := s.docGov.GetDocument(ctx, docID)
|
||||||
|
if err != nil || doc == nil || doc.CurrentState != vocabulary.StateApproved || doc.WorkDir == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b, readErr := os.ReadFile(filepath.Join(doc.WorkDir, workspace.DeliverableKey)); readErr == nil && len(b) > 0 {
|
||||||
|
priorDocs = append(priorDocs, squad.PriorDocItem{
|
||||||
|
DocKey: key, Title: doc.Title, Content: string(b), Stage: "approved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialogueInputs := s.collectSnapshotDialogueInputs(ctx, req.ProjectID, req.UserID)
|
||||||
|
ctxSvc := projectcontext.NewService(s.pool)
|
||||||
|
snap, _ := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{
|
||||||
|
ProjectID: req.ProjectID, TaskKey: req.TaskKey, UserID: req.UserID,
|
||||||
|
ProjectName: req.ProjectName, Facts: facts, Materials: materials, PriorDocs: priorDocs,
|
||||||
|
Knowledge: hits, Dialogue: dialogueInputs.Dialogue, Decisions: dialogueInputs.Decisions,
|
||||||
|
})
|
||||||
|
if snap != nil {
|
||||||
|
snap.ExcludedPrivateCount += dialogueInputs.ExcludedPrivate
|
||||||
|
snap.ExcludedNoAccessCount += dialogueInputs.ExcludedNoAccess
|
||||||
|
if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 {
|
||||||
|
materials = append(materials, extra...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
duties, dutyPolicy, governed := resolveTaskDocumentDuties(ctx, s.pool, req, s.instanceDuty)
|
||||||
|
authorID, authorName := duties.Author.UserID, duties.Author.Name
|
||||||
|
reviewerID, reviewerName := duties.Reviewer.UserID, duties.Reviewer.Name
|
||||||
|
approverID, approverName := duties.Approver.UserID, duties.Approver.Name
|
||||||
|
if authorName == "" {
|
||||||
|
authorName = "编制工程师"
|
||||||
|
}
|
||||||
|
if reviewerName == "" && reviewerID > 0 {
|
||||||
|
reviewerName = "审核负责人"
|
||||||
|
}
|
||||||
|
if approverName == "" && approverID > 0 {
|
||||||
|
approverName = "批准负责人"
|
||||||
|
}
|
||||||
|
|
||||||
|
var docRec *docgov.DocumentRecord
|
||||||
|
var leaseHolderID string
|
||||||
|
var leaseAcquired bool
|
||||||
|
var appliedProposalID string
|
||||||
|
|
||||||
|
if s.docGov != nil && strings.TrimSpace(docID) != "" {
|
||||||
|
docStage := resolveDocStage(req)
|
||||||
|
var createErr error
|
||||||
|
if s.createDocumentHook != nil {
|
||||||
|
docRec, createErr = s.createDocumentHook(
|
||||||
|
ctx, docID, req.ProjectID, req.TaskKey, artifactTitle,
|
||||||
|
docStage, req.WorkDir,
|
||||||
|
authorID, authorName, reviewerID, reviewerName, approverID, approverName,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
docRec, createErr = s.docGov.CreateOrGetDocument(
|
||||||
|
ctx, docID, req.ProjectID, req.TaskKey, artifactTitle,
|
||||||
|
docStage, req.WorkDir,
|
||||||
|
authorID, authorName, reviewerID, reviewerName, approverID, approverName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if createErr != nil {
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
ArtifactTitle: artifactTitle,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: "create document ledger failed: " + createErr.Error(),
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, createErr
|
||||||
|
}
|
||||||
|
if governed && docRec != nil {
|
||||||
|
if lockErr := s.docGov.LockDocumentDuty(ctx, docRec.DocID, dutyPolicy); lockErr != nil {
|
||||||
|
log.Printf("[docgov] lock document duty %s failed: %v", docRec.DocID, lockErr)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
ArtifactTitle: artifactTitle,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: "lock document duty failed: " + lockErr.Error(),
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, lockErr
|
||||||
|
}
|
||||||
|
docRec.DutyLocked = true
|
||||||
|
docRec.DutyPolicy = dutyPolicy
|
||||||
|
}
|
||||||
|
if s.pool != nil && req.ProjectID > 0 {
|
||||||
|
assigned, _, healErr := docgov.HealProjectDocuments(ctx, s.pool, req.ProjectID)
|
||||||
|
if healErr != nil {
|
||||||
|
log.Printf("[docduty] heal project %d after generate failed: %v", req.ProjectID, healErr)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
ArtifactTitle: artifactTitle,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: "document duty heal failed: " + healErr.Error(),
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, healErr
|
||||||
|
}
|
||||||
|
// 受实例治理的文档不得用项目默认职责覆盖内存态返回值。
|
||||||
|
if !governed && docRec != nil {
|
||||||
|
members, creator, loadErr := docduty.LoadProjectMembers(ctx, s.pool, req.ProjectID)
|
||||||
|
if loadErr != nil {
|
||||||
|
log.Printf("[docduty] load members project %d after generate failed: %v", req.ProjectID, loadErr)
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
ArtifactTitle: artifactTitle,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: "document duty load failed: " + loadErr.Error(),
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, loadErr
|
||||||
|
}
|
||||||
|
next, _ := docduty.HealBinding(bindingFromDoc(docRec), assigned, docduty.ActiveMemberIDs(members, creator))
|
||||||
|
writeBindingToDoc(docRec, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ForceDirectEdit {
|
||||||
|
if docRec != nil && docRec.IsLocked {
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
ArtifactTitle: artifactTitle,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: fmt.Sprintf("文档处于【%s】阶段已锁定,禁止 force_direct_edit", docRec.CurrentState),
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, vocabulary.ErrDocumentLocked
|
||||||
|
}
|
||||||
|
// 已认领文档必须有编制人已同意的提案;未认领可直接强制覆写
|
||||||
|
if docRec != nil && docRec.AuthorClaimed {
|
||||||
|
prop, _ := s.docGov.FindApprovedProposal(ctx, docID)
|
||||||
|
if prop == nil {
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
ArtifactTitle: artifactTitle,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: "force_direct_edit 需要编制人已同意的变更提案 (approved_by_author)",
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, vocabulary.ErrForceEditNotAllowed
|
||||||
|
}
|
||||||
|
appliedProposalID = prop.ProposalID
|
||||||
|
}
|
||||||
|
} else if docRec != nil {
|
||||||
|
checkRes, checkErr := s.docGov.CheckModificationPermission(ctx, docID, vocabulary.RoleSystem)
|
||||||
|
if checkErr != nil {
|
||||||
|
if errors.Is(checkErr, vocabulary.ErrDocumentLocked) || errors.Is(checkErr, vocabulary.ErrDocumentBusy) {
|
||||||
|
reason := checkErr.Error()
|
||||||
|
if checkRes != nil && checkRes.Reason != "" {
|
||||||
|
reason = checkRes.Reason
|
||||||
|
}
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
ArtifactTitle: artifactTitle,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: reason,
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, checkErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if checkRes != nil && checkRes.RequiresProposal {
|
||||||
|
// 文档处于已认领状态,必须拦截直接修改,生成变更提案待编制人拍板
|
||||||
|
prop := checkRes.PendingProposal
|
||||||
|
if prop == nil {
|
||||||
|
proposedChanges := fmt.Sprintf("基于项目最新输入【%s - %s】优化交付物技术评估与法规条款", req.DeviceType, req.IntendedUse)
|
||||||
|
impactAnalysis := "更新正文关键章节及提取字段,可能对前序设计基线产生联动影响"
|
||||||
|
riskAssessment := "修改已认领的交付物结论,需由编制负责人核准技术风险与合规结论"
|
||||||
|
prop, _ = s.docGov.ProposeModification(ctx, docID, "agent_squad", proposedChanges, impactAnalysis, riskAssessment)
|
||||||
|
}
|
||||||
|
|
||||||
|
streamer := stream.FromContext(ctx)
|
||||||
|
if streamer != nil {
|
||||||
|
streamer.EmitGovernance(stream.GovernancePayload{
|
||||||
|
DocID: docID,
|
||||||
|
Action: "proposal_required",
|
||||||
|
CurrentState: string(docRec.CurrentState),
|
||||||
|
IsLocked: docRec.IsLocked,
|
||||||
|
AuthorClaimed: docRec.AuthorClaimed,
|
||||||
|
ProposalID: prop.ProposalID,
|
||||||
|
ProposedChanges: prop.ProposedChanges,
|
||||||
|
ImpactSummary: prop.ImpactAnalysis,
|
||||||
|
RiskSummary: prop.RiskAssessment,
|
||||||
|
NotificationTitle: fmt.Sprintf("文档【%s】已被认领,变更已拦截", artifactTitle),
|
||||||
|
NotificationMsg: "系统已拦截智能体小队直接静默覆写,并自动生成变更提案等待编制负责人拍板授权",
|
||||||
|
})
|
||||||
|
streamer.EmitSummary(
|
||||||
|
fmt.Sprintf("⚠️ 文档【%s】变更拦截", artifactTitle),
|
||||||
|
fmt.Sprintf("该文档已由编制负责人认领。本次调整需拍板提案 [%s],确认后方可覆写更新。", prop.ProposalID),
|
||||||
|
80, "V0.5",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
ArtifactTitle: artifactTitle,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: "文档已被编制负责人认领,系统已拦截自动覆写并生成变更提案,请编制负责人拍板确认",
|
||||||
|
DocRecord: docRec,
|
||||||
|
PendingProposal: prop,
|
||||||
|
RequiresAuthorApproval: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过权限检查后获取小队编辑租约(提案拦截路径不持锁)
|
||||||
|
leaseHolderID = strings.TrimSpace(req.RunID)
|
||||||
|
if leaseHolderID == "" {
|
||||||
|
leaseHolderID = fmt.Sprintf("run_%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
leaseTTL := req.Timeout
|
||||||
|
if leaseTTL <= 0 && s.cfg != nil {
|
||||||
|
leaseTTL = s.cfg.AgentTimeout
|
||||||
|
}
|
||||||
|
if leaseTTL <= 0 {
|
||||||
|
leaseTTL = vocabulary.DefaultSquadLeaseTTL
|
||||||
|
}
|
||||||
|
leased, leaseErr := s.docGov.AcquireLease(ctx, docgov.LeaseAcquireRequest{
|
||||||
|
DocID: docID,
|
||||||
|
HolderType: vocabulary.LeaseHolderSquad,
|
||||||
|
HolderID: leaseHolderID,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
TTL: leaseTTL,
|
||||||
|
})
|
||||||
|
if leaseErr != nil {
|
||||||
|
reason := leaseErr.Error()
|
||||||
|
if leased != nil {
|
||||||
|
docRec = leased
|
||||||
|
}
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
ArtifactTitle: artifactTitle,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: reason,
|
||||||
|
DocRecord: docRec,
|
||||||
|
}, leaseErr
|
||||||
|
}
|
||||||
|
docRec = leased
|
||||||
|
leaseAcquired = true
|
||||||
|
|
||||||
|
streamer := stream.FromContext(ctx)
|
||||||
|
if streamer != nil {
|
||||||
|
streamer.EmitGovernance(stream.GovernancePayload{
|
||||||
|
DocID: docID,
|
||||||
|
Action: "lease_acquired",
|
||||||
|
CurrentState: string(docRec.CurrentState),
|
||||||
|
IsLocked: docRec.IsLocked,
|
||||||
|
AuthorClaimed: docRec.AuthorClaimed,
|
||||||
|
NotificationTitle: fmt.Sprintf("文档【%s】编辑租约已获取", artifactTitle),
|
||||||
|
NotificationMsg: "智能体小队正在生成/更新该文档,期间禁止人工编辑与批注",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stopHB := make(chan struct{})
|
||||||
|
defer close(stopHB)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopHB:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
hbCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
_, _ = s.docGov.HeartbeatLease(hbCtx, docID, string(vocabulary.LeaseHolderSquad), leaseHolderID, leaseTTL)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer func() {
|
||||||
|
if leaseAcquired {
|
||||||
|
relCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
_, _ = s.docGov.ReleaseLease(relCtx, docID, string(vocabulary.LeaseHolderSquad), leaseHolderID)
|
||||||
|
cancel()
|
||||||
|
if streamer := stream.FromContext(ctx); streamer != nil {
|
||||||
|
streamer.EmitGovernance(stream.GovernancePayload{
|
||||||
|
DocID: docID,
|
||||||
|
Action: "lease_released",
|
||||||
|
NotificationTitle: fmt.Sprintf("文档【%s】编辑租约已释放", artifactTitle),
|
||||||
|
NotificationMsg: "文档已可继续人工编辑或批注",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 混合上下文装配 (Context Blender)
|
||||||
|
userCtxStr := req.UserContext
|
||||||
|
memBank := req.MemoryBankFiles
|
||||||
|
if memBank == nil {
|
||||||
|
memBank = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.ctxMgr != nil {
|
||||||
|
blended, err := s.buildBlendedSquadContextForUser(ctx, req.ProjectID, req.UserID, req.TaskKey, "squad")
|
||||||
|
if err == nil && blended != nil {
|
||||||
|
if userCtxStr == "" && blended.PersonalizedGuidance != "" {
|
||||||
|
userCtxStr = blended.PersonalizedGuidance
|
||||||
|
}
|
||||||
|
if blended.PersonalizedGuidance != "" {
|
||||||
|
memBank["user_preferences.md"] = blended.PersonalizedGuidance
|
||||||
|
}
|
||||||
|
if len(blended.KeyDecisions) > 0 {
|
||||||
|
memBank["team_decisions.md"] = "# 项目团队关键决策与里程碑记录\n\n" + strings.Join(blended.KeyDecisions, "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
priorDocs := req.PriorDocs
|
||||||
|
if s.stageGate != nil && req.ProjectID > 0 && req.UserID > 0 {
|
||||||
|
priorDocs = s.stageGate.FilterPriorDocs(ctx, req.ProjectID, req.UserID, priorDocs)
|
||||||
|
}
|
||||||
|
|
||||||
|
squadReq := squad.SquadRunRequest{
|
||||||
|
TaskKey: req.TaskKey,
|
||||||
|
Title: req.Title,
|
||||||
|
ProjectName: req.ProjectName,
|
||||||
|
CustomerName: req.CustomerName,
|
||||||
|
DeviceType: req.DeviceType,
|
||||||
|
IntendedUse: req.IntendedUse,
|
||||||
|
TargetMarket: req.TargetMarket,
|
||||||
|
Fields: req.Fields,
|
||||||
|
Materials: materials,
|
||||||
|
Backend: req.Backend,
|
||||||
|
Timeout: req.Timeout,
|
||||||
|
WorkDir: req.WorkDir,
|
||||||
|
CustomSteps: req.CustomSteps,
|
||||||
|
AutoRepair: autoRepairForControlledSquad(req.AutoRepair, docID, docRec),
|
||||||
|
KnowledgeHits: hits,
|
||||||
|
PriorDocs: priorDocs,
|
||||||
|
UserContext: userCtxStr,
|
||||||
|
MemoryBankFiles: memBank,
|
||||||
|
SessionID: req.SessionID,
|
||||||
|
UserID: req.UserID,
|
||||||
|
ProjectID: req.ProjectID,
|
||||||
|
RunID: req.RunID,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.squad.ExecuteTask(ctx, squadReq)
|
||||||
|
if err != nil {
|
||||||
|
if res != nil {
|
||||||
|
return &TaskRunResult{
|
||||||
|
TaskKey: res.TaskKey,
|
||||||
|
ArtifactTitle: res.ArtifactTitle,
|
||||||
|
WorkDir: res.WorkDir,
|
||||||
|
BackendUsed: res.BackendUsed,
|
||||||
|
Duration: res.Duration,
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: res.ErrorReason,
|
||||||
|
AdvisorBrief: res.AdvisorBrief,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.finalizeSquadDeliverable(ctx, req, res, docID, docRec, appliedProposalID, upstreamSRDoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// qualityBlocksControlledProjection 质量门禁未过时禁止任何正式受控投影。
|
||||||
|
// 独立于 Success,避免 Success 误为 true 时仍 Materialize。
|
||||||
+1
-981
@@ -1,19 +1,13 @@
|
|||||||
package freedoc
|
package freedoc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"chat-agent-system/internal/ctxmgr"
|
"chat-agent-system/internal/ctxmgr"
|
||||||
"chat-agent-system/internal/docrevision"
|
"chat-agent-system/internal/docrevision"
|
||||||
"chat-agent-system/internal/replyexport"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,977 +46,3 @@ func computeSHA256(text string) string {
|
|||||||
sum := sha256.Sum256([]byte(text))
|
sum := sha256.Sum256([]byte(text))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateFromMessage 从单条智能体回复保存为私密文档(幂等)
|
|
||||||
func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateFromMessageRequest) (*FreeformDocDetail, bool, error) {
|
|
||||||
if req.MessageID <= 0 {
|
|
||||||
return nil, false, fmt.Errorf("%w: invalid message_id", ErrInvalidParam)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 获取源消息
|
|
||||||
if s.ctxMgr == nil {
|
|
||||||
return nil, false, errors.New("ctxMgr is not configured")
|
|
||||||
}
|
|
||||||
msg, err := s.ctxMgr.GetMessageByID(ctx, req.MessageID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, fmt.Errorf("lookup message: %w", err)
|
|
||||||
}
|
|
||||||
if msg == nil {
|
|
||||||
return nil, false, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 校验导出资格红线:必须是助手且非纯卡片且 export_policy != 'none'
|
|
||||||
if msg.Role != "assistant" || msg.ContentKind == "card_only" || msg.ExportPolicy == "none" || len(msg.Content) == 0 {
|
|
||||||
return nil, false, ErrMessageNotExportable
|
|
||||||
}
|
|
||||||
|
|
||||||
content := msg.Content
|
|
||||||
contentHash := computeSHA256(content)
|
|
||||||
if req.ExpectedSHA256 != "" && req.ExpectedSHA256 != contentHash {
|
|
||||||
return nil, false, ErrConflict
|
|
||||||
}
|
|
||||||
|
|
||||||
title := req.Title
|
|
||||||
if title == "" {
|
|
||||||
title = replyexport.DeriveTitle(content, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
projectID := req.ProjectID
|
|
||||||
if projectID <= 0 {
|
|
||||||
projectID = 1 // 兜底默认项目
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 幂等性检查:若该用户针对该消息已生成且未删除,直接返回
|
|
||||||
if s.pool != nil {
|
|
||||||
var existingDoc FreeformDocument
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
|
||||||
title, governance_mode, space, legacy_version_id, current_sha256,
|
|
||||||
is_deleted, deleted_at, created_at, updated_at
|
|
||||||
FROM freeform_documents
|
|
||||||
WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE
|
|
||||||
LIMIT 1
|
|
||||||
`, userID, req.MessageID)
|
|
||||||
if err := row.Scan(
|
|
||||||
&existingDoc.DocID, &existingDoc.ProjectID, &existingDoc.OwnerUserID, &existingDoc.SourceMessageID,
|
|
||||||
&existingDoc.SourceContentSHA256, &existingDoc.Title, &existingDoc.GovernanceMode,
|
|
||||||
&existingDoc.Space, &existingDoc.CurrentVersionID, &existingDoc.CurrentSHA256,
|
|
||||||
&existingDoc.IsDeleted, &existingDoc.DeletedAt, &existingDoc.CreatedAt, &existingDoc.UpdatedAt,
|
|
||||||
); err == nil {
|
|
||||||
// 获取当前版本的 markdown
|
|
||||||
var curContent string
|
|
||||||
_ = s.pool.QueryRow(ctx, `
|
|
||||||
SELECT content_markdown FROM freeform_document_versions
|
|
||||||
WHERE doc_id = $1 AND version_no = $2
|
|
||||||
`, existingDoc.DocID, existingDoc.CurrentVersionID).Scan(&curContent)
|
|
||||||
return &FreeformDocDetail{
|
|
||||||
Document: existingDoc,
|
|
||||||
Content: curContent,
|
|
||||||
}, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
INSERT INTO freeform_documents (
|
|
||||||
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
|
||||||
title, governance_mode, space, legacy_version_id, current_sha256,
|
|
||||||
is_deleted, created_at, updated_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8)
|
|
||||||
`, docID, projectID, userID, req.MessageID, contentHash, title, contentHash, now)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, fmt.Errorf("insert freeform_doc: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
INSERT INTO freeform_document_versions (
|
|
||||||
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
|
|
||||||
) VALUES ($1, 1, $2, $3, $4, '从智能体回复保存', $5)
|
|
||||||
`, docID, contentHash, content, userID, now)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, fmt.Errorf("insert version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
|
||||||
VALUES ($1, $2, 'create_from_message', TRUE, $3)
|
|
||||||
`, docID, userID, now)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, fmt.Errorf("insert audit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
createdDoc := FreeformDocument{
|
|
||||||
DocID: docID,
|
|
||||||
ProjectID: projectID,
|
|
||||||
OwnerUserID: userID,
|
|
||||||
SourceMessageID: &req.MessageID,
|
|
||||||
SourceContentSHA256: contentHash,
|
|
||||||
Title: title,
|
|
||||||
GovernanceMode: "freeform",
|
|
||||||
Space: "my_private",
|
|
||||||
CurrentVersionID: 1,
|
|
||||||
CurrentSHA256: contentHash,
|
|
||||||
IsDeleted: false,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
return &FreeformDocDetail{
|
|
||||||
Document: createdDoc,
|
|
||||||
Content: content,
|
|
||||||
}, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存备用实现
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
for _, d := range s.memDocs {
|
|
||||||
if d.OwnerUserID == userID && d.SourceMessageID != nil && *d.SourceMessageID == req.MessageID && !d.IsDeleted {
|
|
||||||
versions := s.memVersions[d.DocID]
|
|
||||||
content := ""
|
|
||||||
if len(versions) > 0 {
|
|
||||||
content = versions[len(versions)-1].ContentMarkdown
|
|
||||||
}
|
|
||||||
return &FreeformDocDetail{
|
|
||||||
Document: *d,
|
|
||||||
Content: content,
|
|
||||||
}, true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
|
|
||||||
now := time.Now()
|
|
||||||
newDoc := &FreeformDocument{
|
|
||||||
DocID: docID,
|
|
||||||
ProjectID: projectID,
|
|
||||||
OwnerUserID: userID,
|
|
||||||
SourceMessageID: &req.MessageID,
|
|
||||||
SourceContentSHA256: contentHash,
|
|
||||||
Title: title,
|
|
||||||
GovernanceMode: "freeform",
|
|
||||||
Space: "my_private",
|
|
||||||
CurrentVersionID: 1,
|
|
||||||
CurrentSHA256: contentHash,
|
|
||||||
IsDeleted: false,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
s.memDocs[docID] = newDoc
|
|
||||||
s.memVersions[docID] = []*FreeformDocumentVersion{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
DocID: docID,
|
|
||||||
VersionNo: 1,
|
|
||||||
ContentSHA256: contentHash,
|
|
||||||
ContentMarkdown: content,
|
|
||||||
CreatedBy: userID,
|
|
||||||
ChangeSummary: "从智能体回复保存",
|
|
||||||
CreatedAt: now,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return &FreeformDocDetail{
|
|
||||||
Document: *newDoc,
|
|
||||||
Content: content,
|
|
||||||
}, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientKeyLookup(userID int, clientKey string) string {
|
|
||||||
return fmt.Sprintf("%d|%s", userID, strings.TrimSpace(clientKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) bootstrapLedger(ctx context.Context, userID int, detail *FreeformDocDetail) {
|
|
||||||
if s == nil || s.docRev == nil || detail == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
md := detail.Content
|
|
||||||
if strings.TrimSpace(md) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = s.docRev.EnsureBootstrap(ctx, docrevision.BootstrapRequest{
|
|
||||||
DocID: detail.Document.DocID,
|
|
||||||
Kind: "freeform",
|
|
||||||
Markdown: md,
|
|
||||||
ProjectID: int64(detail.Document.ProjectID),
|
|
||||||
LifecycleState: "drafting",
|
|
||||||
Actor: docrevision.ActorSnapshot{
|
|
||||||
DisplayName: "freeform-owner",
|
|
||||||
Role: "owner",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) findByClientKeyPG(ctx context.Context, userID int, clientKey string) (*FreeformDocDetail, error) {
|
|
||||||
var docID string
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT doc_id FROM freeform_documents
|
|
||||||
WHERE owner_user_id = $1 AND client_key = $2 AND is_deleted = FALSE
|
|
||||||
LIMIT 1
|
|
||||||
`, userID, clientKey).Scan(&docID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.GetDocument(ctx, userID, docID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateDocument 直接创建自由文档(上传附件 / 自定义正文的服务端事实源)。
|
|
||||||
// client_key 持久化于 freeform_documents.client_key,与 owner 唯一;禁止污染 source_content_sha256。
|
|
||||||
// 创建后立即 EnsureBootstrap 版本账本,使 revision-state 可读、PATCH 可持久化。
|
|
||||||
func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFreeformDocRequest) (*FreeformDocDetail, error) {
|
|
||||||
title := strings.TrimSpace(req.Title)
|
|
||||||
content := req.ContentMarkdown
|
|
||||||
if title == "" {
|
|
||||||
return nil, fmt.Errorf("%w: title required", ErrInvalidParam)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(content) == "" {
|
|
||||||
return nil, fmt.Errorf("%w: content_markdown required", ErrInvalidParam)
|
|
||||||
}
|
|
||||||
space := strings.TrimSpace(req.Space)
|
|
||||||
if space == "" {
|
|
||||||
space = "my_private"
|
|
||||||
}
|
|
||||||
// Direct create is upload/private registration only; project_shared must go through ShareDocument.
|
|
||||||
if space != "my_private" {
|
|
||||||
return nil, fmt.Errorf("%w: direct create only allows my_private; use share API", ErrInvalidParam)
|
|
||||||
}
|
|
||||||
projectID := req.ProjectID
|
|
||||||
if projectID <= 0 {
|
|
||||||
return nil, fmt.Errorf("%w: project_id required", ErrInvalidParam)
|
|
||||||
}
|
|
||||||
clientKey := strings.TrimSpace(req.ClientKey)
|
|
||||||
var clientKeyPtr *string
|
|
||||||
if clientKey != "" {
|
|
||||||
clientKeyPtr = &clientKey
|
|
||||||
}
|
|
||||||
|
|
||||||
if clientKey != "" {
|
|
||||||
if s.pool != nil {
|
|
||||||
existing, err := s.findByClientKeyPG(ctx, userID, clientKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
s.mu.RLock()
|
|
||||||
existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if existingID != "" {
|
|
||||||
return s.GetDocument(ctx, userID, existingID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentHash := computeSHA256(content)
|
|
||||||
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
if s.pool != nil {
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
INSERT INTO freeform_documents (
|
|
||||||
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
|
||||||
client_key, title, governance_mode, space, legacy_version_id, current_sha256,
|
|
||||||
is_deleted, created_at, updated_at
|
|
||||||
) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, FALSE, $8, $8)
|
|
||||||
`, docID, projectID, userID, contentHash, clientKeyPtr, title, space, now)
|
|
||||||
if err != nil {
|
|
||||||
// Unique race: another request inserted the same owner+client_key
|
|
||||||
if clientKey != "" {
|
|
||||||
if existing, findErr := s.findByClientKeyPG(ctx, userID, clientKey); findErr == nil && existing != nil {
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("insert freeform_doc: %w", err)
|
|
||||||
}
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
INSERT INTO freeform_document_versions (
|
|
||||||
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
|
|
||||||
) VALUES ($1, 1, $2, $3, $4, '上传附件注册', $5)
|
|
||||||
`, docID, contentHash, content, userID, now)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("insert version: %w", err)
|
|
||||||
}
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
|
||||||
VALUES ($1, $2, 'create_upload', TRUE, $3)
|
|
||||||
`, docID, userID, now)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("insert audit: %w", err)
|
|
||||||
}
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
detail := &FreeformDocDetail{
|
|
||||||
Document: FreeformDocument{
|
|
||||||
DocID: docID,
|
|
||||||
ProjectID: projectID,
|
|
||||||
OwnerUserID: userID,
|
|
||||||
SourceContentSHA256: contentHash,
|
|
||||||
ClientKey: clientKeyPtr,
|
|
||||||
Title: title,
|
|
||||||
GovernanceMode: "freeform",
|
|
||||||
Space: space,
|
|
||||||
CurrentVersionID: 1,
|
|
||||||
CurrentSHA256: contentHash,
|
|
||||||
IsDeleted: false,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
},
|
|
||||||
Content: content,
|
|
||||||
}
|
|
||||||
s.bootstrapLedger(ctx, userID, detail)
|
|
||||||
return detail, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if clientKey != "" {
|
|
||||||
if existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]; existingID != "" {
|
|
||||||
doc := s.memDocs[existingID]
|
|
||||||
versions := s.memVersions[existingID]
|
|
||||||
cur := ""
|
|
||||||
if len(versions) > 0 {
|
|
||||||
cur = versions[len(versions)-1].ContentMarkdown
|
|
||||||
}
|
|
||||||
if doc != nil {
|
|
||||||
return &FreeformDocDetail{Document: *doc, Content: cur}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newDoc := &FreeformDocument{
|
|
||||||
DocID: docID,
|
|
||||||
ProjectID: projectID,
|
|
||||||
OwnerUserID: userID,
|
|
||||||
SourceContentSHA256: contentHash,
|
|
||||||
ClientKey: clientKeyPtr,
|
|
||||||
Title: title,
|
|
||||||
GovernanceMode: "freeform",
|
|
||||||
Space: space,
|
|
||||||
CurrentVersionID: 1,
|
|
||||||
CurrentSHA256: contentHash,
|
|
||||||
IsDeleted: false,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
s.memDocs[docID] = newDoc
|
|
||||||
s.memVersions[docID] = []*FreeformDocumentVersion{{
|
|
||||||
ID: 1, DocID: docID, VersionNo: 1, ContentSHA256: contentHash,
|
|
||||||
ContentMarkdown: content, CreatedBy: userID, ChangeSummary: "上传附件注册", CreatedAt: now,
|
|
||||||
}}
|
|
||||||
if clientKey != "" {
|
|
||||||
s.memClientKeys[clientKeyLookup(userID, clientKey)] = docID
|
|
||||||
}
|
|
||||||
detail := &FreeformDocDetail{Document: *newDoc, Content: content}
|
|
||||||
s.bootstrapLedger(ctx, userID, detail)
|
|
||||||
return detail, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDocument 获取文档详情及内容,严格执行私密与共享访问控制
|
|
||||||
func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*FreeformDocDetail, error) {
|
|
||||||
if docID == "" {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pool != nil {
|
|
||||||
var doc FreeformDocument
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
|
||||||
title, governance_mode, space, legacy_version_id, current_sha256,
|
|
||||||
is_deleted, deleted_at, created_at, updated_at
|
|
||||||
FROM freeform_documents
|
|
||||||
WHERE doc_id = $1
|
|
||||||
`, docID)
|
|
||||||
if err := row.Scan(
|
|
||||||
&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID,
|
|
||||||
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
|
||||||
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
|
||||||
&doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if doc.IsDeleted {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// 私密隔离红线:my_private 只有 owner 可读
|
|
||||||
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
|
||||||
_, _ = s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO document_access_audit (doc_id, user_id, action, success)
|
|
||||||
VALUES ($1, $2, 'read_denied', FALSE)
|
|
||||||
`, docID, userID)
|
|
||||||
return nil, ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
var content string
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT content_markdown FROM freeform_document_versions
|
|
||||||
WHERE doc_id = $1 AND version_no = $2
|
|
||||||
`, doc.DocID, doc.CurrentVersionID).Scan(&content)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO document_access_audit (doc_id, user_id, action, success)
|
|
||||||
VALUES ($1, $2, 'read', TRUE)
|
|
||||||
`, docID, userID)
|
|
||||||
|
|
||||||
return &FreeformDocDetail{
|
|
||||||
Document: doc,
|
|
||||||
Content: content,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存模式
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
doc, ok := s.memDocs[docID]
|
|
||||||
if !ok || doc.IsDeleted {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
|
||||||
return nil, ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
versions := s.memVersions[docID]
|
|
||||||
content := ""
|
|
||||||
for _, v := range versions {
|
|
||||||
if v.VersionNo == doc.CurrentVersionID {
|
|
||||||
content = v.ContentMarkdown
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &FreeformDocDetail{
|
|
||||||
Document: *doc,
|
|
||||||
Content: content,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListDocuments 按空间与权限列出自由文档概要
|
|
||||||
func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64, space string) ([]FreeformDocSummary, error) {
|
|
||||||
if space != "my_private" && space != "project_shared" && space != "trash" {
|
|
||||||
space = "my_private"
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pool != nil {
|
|
||||||
var rows pgx.Rows
|
|
||||||
var err error
|
|
||||||
if space == "my_private" {
|
|
||||||
// 我的私密:属于当前用户
|
|
||||||
if projectID > 0 {
|
|
||||||
rows, err = s.pool.Query(ctx, `
|
|
||||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
|
||||||
FROM freeform_documents
|
|
||||||
WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`, userID, projectID)
|
|
||||||
} else {
|
|
||||||
rows, err = s.pool.Query(ctx, `
|
|
||||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
|
||||||
FROM freeform_documents
|
|
||||||
WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`, userID)
|
|
||||||
}
|
|
||||||
} else if space == "project_shared" {
|
|
||||||
// 项目共享参考:属于当前项目的所有公开参考
|
|
||||||
rows, err = s.pool.Query(ctx, `
|
|
||||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
|
||||||
FROM freeform_documents
|
|
||||||
WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`, projectID)
|
|
||||||
} else {
|
|
||||||
// 回收站:当前用户删除的
|
|
||||||
rows, err = s.pool.Query(ctx, `
|
|
||||||
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
|
||||||
FROM freeform_documents
|
|
||||||
WHERE owner_user_id = $1 AND is_deleted = TRUE
|
|
||||||
ORDER BY deleted_at DESC
|
|
||||||
`, userID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []FreeformDocSummary
|
|
||||||
for rows.Next() {
|
|
||||||
var sm FreeformDocSummary
|
|
||||||
if err := rows.Scan(
|
|
||||||
&sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space,
|
|
||||||
&sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results = append(results, sm)
|
|
||||||
}
|
|
||||||
return results, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存模式
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
var results []FreeformDocSummary
|
|
||||||
for _, d := range s.memDocs {
|
|
||||||
if space == "my_private" {
|
|
||||||
if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted {
|
|
||||||
if projectID <= 0 || d.ProjectID == projectID {
|
|
||||||
results = append(results, FreeformDocSummary{
|
|
||||||
DocID: d.DocID,
|
|
||||||
ProjectID: d.ProjectID,
|
|
||||||
OwnerUserID: d.OwnerUserID,
|
|
||||||
Title: d.Title,
|
|
||||||
Space: d.Space,
|
|
||||||
CurrentVersionID: d.CurrentVersionID,
|
|
||||||
CurrentSHA256: d.CurrentSHA256,
|
|
||||||
CreatedAt: d.CreatedAt,
|
|
||||||
UpdatedAt: d.UpdatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if space == "project_shared" {
|
|
||||||
if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted {
|
|
||||||
results = append(results, FreeformDocSummary{
|
|
||||||
DocID: d.DocID,
|
|
||||||
ProjectID: d.ProjectID,
|
|
||||||
OwnerUserID: d.OwnerUserID,
|
|
||||||
Title: d.Title,
|
|
||||||
Space: d.Space,
|
|
||||||
CurrentVersionID: d.CurrentVersionID,
|
|
||||||
CurrentSHA256: d.CurrentSHA256,
|
|
||||||
CreatedAt: d.CreatedAt,
|
|
||||||
UpdatedAt: d.UpdatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateDocument 更新文档正文或标题,支持 If-Match 乐观并发控制
|
|
||||||
func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, req UpdateFreeformDocRequest) (*FreeformDocDetail, error) {
|
|
||||||
if docID == "" {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pool != nil {
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
var doc FreeformDocument
|
|
||||||
row := tx.QueryRow(ctx, `
|
|
||||||
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
|
||||||
title, governance_mode, space, legacy_version_id, current_sha256,
|
|
||||||
is_deleted, created_at, updated_at
|
|
||||||
FROM freeform_documents
|
|
||||||
WHERE doc_id = $1 FOR UPDATE
|
|
||||||
`, docID)
|
|
||||||
if err := row.Scan(
|
|
||||||
&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID,
|
|
||||||
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
|
||||||
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
|
||||||
&doc.IsDeleted, &doc.CreatedAt, &doc.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if doc.IsDeleted {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// 私密文档非本人严禁修改
|
|
||||||
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
|
||||||
return nil, ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
// 乐观并发冲突检验
|
|
||||||
if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID {
|
|
||||||
return nil, ErrConflict
|
|
||||||
}
|
|
||||||
if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 {
|
|
||||||
return nil, ErrConflict
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
newTitle := doc.Title
|
|
||||||
if req.Title != nil && *req.Title != "" {
|
|
||||||
newTitle = *req.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
curContent := ""
|
|
||||||
_ = tx.QueryRow(ctx, `SELECT content_markdown FROM freeform_document_versions WHERE doc_id = $1 AND version_no = $2`, doc.DocID, doc.CurrentVersionID).Scan(&curContent)
|
|
||||||
|
|
||||||
newContent := curContent
|
|
||||||
if req.ContentMarkdown != nil {
|
|
||||||
newContent = *req.ContentMarkdown
|
|
||||||
}
|
|
||||||
|
|
||||||
newHash := computeSHA256(newContent)
|
|
||||||
if newHash != doc.CurrentSHA256 {
|
|
||||||
// 内容变更,生成新版本
|
|
||||||
nextVersion := doc.CurrentVersionID + 1
|
|
||||||
summary := req.ChangeSummary
|
|
||||||
if summary == "" {
|
|
||||||
summary = "编辑修改正文"
|
|
||||||
}
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
INSERT INTO freeform_document_versions (
|
|
||||||
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
`, doc.DocID, nextVersion, newHash, newContent, userID, summary, now)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("insert version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
UPDATE freeform_documents
|
|
||||||
SET title = $1, legacy_version_id = $2, current_sha256 = $3, updated_at = $4
|
|
||||||
WHERE doc_id = $5
|
|
||||||
`, newTitle, nextVersion, newHash, now, doc.DocID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("update doc: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.CurrentVersionID = nextVersion
|
|
||||||
doc.CurrentSHA256 = newHash
|
|
||||||
} else if newTitle != doc.Title {
|
|
||||||
// 仅标题变更
|
|
||||||
_, err = tx.Exec(ctx, `
|
|
||||||
UPDATE freeform_documents
|
|
||||||
SET title = $1, updated_at = $2
|
|
||||||
WHERE doc_id = $3
|
|
||||||
`, newTitle, now, doc.DocID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("update doc title: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.Title = newTitle
|
|
||||||
doc.UpdatedAt = now
|
|
||||||
|
|
||||||
_, _ = tx.Exec(ctx, `
|
|
||||||
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
|
||||||
VALUES ($1, $2, 'update', TRUE, $3)
|
|
||||||
`, doc.DocID, userID, now)
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &FreeformDocDetail{
|
|
||||||
Document: doc,
|
|
||||||
Content: newContent,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存模式
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
doc, ok := s.memDocs[docID]
|
|
||||||
if !ok || doc.IsDeleted {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
|
||||||
return nil, ErrForbidden
|
|
||||||
}
|
|
||||||
if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID {
|
|
||||||
return nil, ErrConflict
|
|
||||||
}
|
|
||||||
if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 {
|
|
||||||
return nil, ErrConflict
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
newTitle := doc.Title
|
|
||||||
if req.Title != nil && *req.Title != "" {
|
|
||||||
newTitle = *req.Title
|
|
||||||
}
|
|
||||||
doc.Title = newTitle
|
|
||||||
|
|
||||||
curContent := ""
|
|
||||||
versions := s.memVersions[docID]
|
|
||||||
if len(versions) > 0 {
|
|
||||||
curContent = versions[len(versions)-1].ContentMarkdown
|
|
||||||
}
|
|
||||||
|
|
||||||
newContent := curContent
|
|
||||||
if req.ContentMarkdown != nil {
|
|
||||||
newContent = *req.ContentMarkdown
|
|
||||||
}
|
|
||||||
|
|
||||||
newHash := computeSHA256(newContent)
|
|
||||||
if newHash != doc.CurrentSHA256 {
|
|
||||||
nextVersion := doc.CurrentVersionID + 1
|
|
||||||
doc.CurrentVersionID = nextVersion
|
|
||||||
doc.CurrentSHA256 = newHash
|
|
||||||
s.memVersions[docID] = append(s.memVersions[docID], &FreeformDocumentVersion{
|
|
||||||
ID: int64(len(s.memVersions[docID]) + 1),
|
|
||||||
DocID: docID,
|
|
||||||
VersionNo: nextVersion,
|
|
||||||
ContentSHA256: newHash,
|
|
||||||
ContentMarkdown: newContent,
|
|
||||||
CreatedBy: userID,
|
|
||||||
ChangeSummary: req.ChangeSummary,
|
|
||||||
CreatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
doc.UpdatedAt = now
|
|
||||||
detail := &FreeformDocDetail{
|
|
||||||
Document: *doc,
|
|
||||||
Content: newContent,
|
|
||||||
}
|
|
||||||
s.syncLedgerLocked(ctx, userID, detail)
|
|
||||||
return detail, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncLedgerLocked imports legacy checkpoints then records human revisions on the shared ledger.
|
|
||||||
func (s *Service) syncLedgerLocked(ctx context.Context, userID int, detail *FreeformDocDetail) {
|
|
||||||
if s.docRev == nil || detail == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
doc := detail.Document
|
|
||||||
checkpoints := make([]struct {
|
|
||||||
VersionNo int
|
|
||||||
Markdown string
|
|
||||||
SHA string
|
|
||||||
}, 0, len(s.memVersions[doc.DocID]))
|
|
||||||
for _, v := range s.memVersions[doc.DocID] {
|
|
||||||
checkpoints = append(checkpoints, struct {
|
|
||||||
VersionNo int
|
|
||||||
Markdown string
|
|
||||||
SHA string
|
|
||||||
}{VersionNo: v.VersionNo, Markdown: v.ContentMarkdown, SHA: v.ContentSHA256})
|
|
||||||
}
|
|
||||||
prior := checkpoints
|
|
||||||
if len(prior) > 0 {
|
|
||||||
prior = prior[:len(prior)-1]
|
|
||||||
}
|
|
||||||
st, err := s.docRev.ImportFreeformLegacy(ctx, doc.DocID, int64(userID), int64(doc.ProjectID), prior, detail.Content)
|
|
||||||
if err != nil || st == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = s.docRev.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
|
|
||||||
DocID: doc.DocID, Markdown: detail.Content,
|
|
||||||
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
|
|
||||||
IdempotencyKey: fmt.Sprintf("freeform:%s:%s", doc.DocID, detail.Document.CurrentSHA256),
|
|
||||||
ActorUserID: int64(userID),
|
|
||||||
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "freeform-owner", Role: "owner"},
|
|
||||||
LifecycleState: "drafting",
|
|
||||||
ProjectID: int64(doc.ProjectID),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShareDocument 将私密文档共享至项目参考库(仅 owner 可操作)
|
|
||||||
func (s *Service) ShareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) {
|
|
||||||
if docID == "" {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pool != nil {
|
|
||||||
var doc FreeformDocument
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT doc_id, project_id, owner_user_id, space, is_deleted
|
|
||||||
FROM freeform_documents WHERE doc_id = $1
|
|
||||||
`, docID)
|
|
||||||
if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if doc.IsDeleted {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if doc.OwnerUserID != userID {
|
|
||||||
return nil, ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE freeform_documents
|
|
||||||
SET space = 'project_shared', updated_at = $1
|
|
||||||
WHERE doc_id = $2
|
|
||||||
`, now, docID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
|
||||||
VALUES ($1, $2, 'share', TRUE, $3)
|
|
||||||
`, docID, userID, now)
|
|
||||||
|
|
||||||
doc.Space = "project_shared"
|
|
||||||
doc.UpdatedAt = now
|
|
||||||
return &doc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存模式
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
doc, ok := s.memDocs[docID]
|
|
||||||
if !ok || doc.IsDeleted {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if doc.OwnerUserID != userID {
|
|
||||||
return nil, ErrForbidden
|
|
||||||
}
|
|
||||||
doc.Space = "project_shared"
|
|
||||||
doc.UpdatedAt = time.Now()
|
|
||||||
return doc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnshareDocument 撤回共享,恢复为个人私密
|
|
||||||
func (s *Service) UnshareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) {
|
|
||||||
if docID == "" {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pool != nil {
|
|
||||||
var doc FreeformDocument
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT doc_id, project_id, owner_user_id, space, is_deleted
|
|
||||||
FROM freeform_documents WHERE doc_id = $1
|
|
||||||
`, docID)
|
|
||||||
if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if doc.IsDeleted {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if doc.OwnerUserID != userID {
|
|
||||||
return nil, ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE freeform_documents
|
|
||||||
SET space = 'my_private', updated_at = $1
|
|
||||||
WHERE doc_id = $2
|
|
||||||
`, now, docID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
|
||||||
VALUES ($1, $2, 'unshare', TRUE, $3)
|
|
||||||
`, docID, userID, now)
|
|
||||||
|
|
||||||
doc.Space = "my_private"
|
|
||||||
doc.UpdatedAt = now
|
|
||||||
return &doc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存模式
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
doc, ok := s.memDocs[docID]
|
|
||||||
if !ok || doc.IsDeleted {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if doc.OwnerUserID != userID {
|
|
||||||
return nil, ErrForbidden
|
|
||||||
}
|
|
||||||
doc.Space = "my_private"
|
|
||||||
doc.UpdatedAt = time.Now()
|
|
||||||
return doc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteDocument 软删除文档(仅 owner 可操作)
|
|
||||||
func (s *Service) DeleteDocument(ctx context.Context, userID int, docID string) error {
|
|
||||||
if docID == "" {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pool != nil {
|
|
||||||
var doc FreeformDocument
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT doc_id, owner_user_id, is_deleted
|
|
||||||
FROM freeform_documents WHERE doc_id = $1
|
|
||||||
`, docID)
|
|
||||||
if err := row.Scan(&doc.DocID, &doc.OwnerUserID, &doc.IsDeleted); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if doc.IsDeleted {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if doc.OwnerUserID != userID {
|
|
||||||
return ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE freeform_documents
|
|
||||||
SET is_deleted = TRUE, deleted_at = $1, space = 'trash'
|
|
||||||
WHERE doc_id = $2
|
|
||||||
`, now, docID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
|
||||||
VALUES ($1, $2, 'delete', TRUE, $3)
|
|
||||||
`, docID, userID, now)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存模式
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
doc, ok := s.memDocs[docID]
|
|
||||||
if !ok || doc.IsDeleted {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
if doc.OwnerUserID != userID {
|
|
||||||
return ErrForbidden
|
|
||||||
}
|
|
||||||
doc.IsDeleted = true
|
|
||||||
now := time.Now()
|
|
||||||
doc.DeletedAt = &now
|
|
||||||
doc.Space = "trash"
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
package freedoc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/docrevision"
|
||||||
|
"chat-agent-system/internal/replyexport"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateFromMessage 从单条智能体回复保存为私密文档(幂等)
|
||||||
|
func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateFromMessageRequest) (*FreeformDocDetail, bool, error) {
|
||||||
|
if req.MessageID <= 0 {
|
||||||
|
return nil, false, fmt.Errorf("%w: invalid message_id", ErrInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 获取源消息
|
||||||
|
if s.ctxMgr == nil {
|
||||||
|
return nil, false, errors.New("ctxMgr is not configured")
|
||||||
|
}
|
||||||
|
msg, err := s.ctxMgr.GetMessageByID(ctx, req.MessageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("lookup message: %w", err)
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
return nil, false, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 校验导出资格红线:必须是助手且非纯卡片且 export_policy != 'none'
|
||||||
|
if msg.Role != "assistant" || msg.ContentKind == "card_only" || msg.ExportPolicy == "none" || len(msg.Content) == 0 {
|
||||||
|
return nil, false, ErrMessageNotExportable
|
||||||
|
}
|
||||||
|
|
||||||
|
content := msg.Content
|
||||||
|
contentHash := computeSHA256(content)
|
||||||
|
if req.ExpectedSHA256 != "" && req.ExpectedSHA256 != contentHash {
|
||||||
|
return nil, false, ErrConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
title := req.Title
|
||||||
|
if title == "" {
|
||||||
|
title = replyexport.DeriveTitle(content, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := req.ProjectID
|
||||||
|
if projectID <= 0 {
|
||||||
|
projectID = 1 // 兜底默认项目
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 幂等性检查:若该用户针对该消息已生成且未删除,直接返回
|
||||||
|
if s.pool != nil {
|
||||||
|
var existingDoc FreeformDocument
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||||
|
title, governance_mode, space, legacy_version_id, current_sha256,
|
||||||
|
is_deleted, deleted_at, created_at, updated_at
|
||||||
|
FROM freeform_documents
|
||||||
|
WHERE owner_user_id = $1 AND source_message_id = $2 AND is_deleted = FALSE
|
||||||
|
LIMIT 1
|
||||||
|
`, userID, req.MessageID)
|
||||||
|
if err := row.Scan(
|
||||||
|
&existingDoc.DocID, &existingDoc.ProjectID, &existingDoc.OwnerUserID, &existingDoc.SourceMessageID,
|
||||||
|
&existingDoc.SourceContentSHA256, &existingDoc.Title, &existingDoc.GovernanceMode,
|
||||||
|
&existingDoc.Space, &existingDoc.CurrentVersionID, &existingDoc.CurrentSHA256,
|
||||||
|
&existingDoc.IsDeleted, &existingDoc.DeletedAt, &existingDoc.CreatedAt, &existingDoc.UpdatedAt,
|
||||||
|
); err == nil {
|
||||||
|
// 获取当前版本的 markdown
|
||||||
|
var curContent string
|
||||||
|
_ = s.pool.QueryRow(ctx, `
|
||||||
|
SELECT content_markdown FROM freeform_document_versions
|
||||||
|
WHERE doc_id = $1 AND version_no = $2
|
||||||
|
`, existingDoc.DocID, existingDoc.CurrentVersionID).Scan(&curContent)
|
||||||
|
return &FreeformDocDetail{
|
||||||
|
Document: existingDoc,
|
||||||
|
Content: curContent,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO freeform_documents (
|
||||||
|
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||||
|
title, governance_mode, space, legacy_version_id, current_sha256,
|
||||||
|
is_deleted, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, 'freeform', 'my_private', 1, $7, FALSE, $8, $8)
|
||||||
|
`, docID, projectID, userID, req.MessageID, contentHash, title, contentHash, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("insert freeform_doc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO freeform_document_versions (
|
||||||
|
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
|
||||||
|
) VALUES ($1, 1, $2, $3, $4, '从智能体回复保存', $5)
|
||||||
|
`, docID, contentHash, content, userID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("insert version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
||||||
|
VALUES ($1, $2, 'create_from_message', TRUE, $3)
|
||||||
|
`, docID, userID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("insert audit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
createdDoc := FreeformDocument{
|
||||||
|
DocID: docID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
OwnerUserID: userID,
|
||||||
|
SourceMessageID: &req.MessageID,
|
||||||
|
SourceContentSHA256: contentHash,
|
||||||
|
Title: title,
|
||||||
|
GovernanceMode: "freeform",
|
||||||
|
Space: "my_private",
|
||||||
|
CurrentVersionID: 1,
|
||||||
|
CurrentSHA256: contentHash,
|
||||||
|
IsDeleted: false,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
return &FreeformDocDetail{
|
||||||
|
Document: createdDoc,
|
||||||
|
Content: content,
|
||||||
|
}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存备用实现
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for _, d := range s.memDocs {
|
||||||
|
if d.OwnerUserID == userID && d.SourceMessageID != nil && *d.SourceMessageID == req.MessageID && !d.IsDeleted {
|
||||||
|
versions := s.memVersions[d.DocID]
|
||||||
|
content := ""
|
||||||
|
if len(versions) > 0 {
|
||||||
|
content = versions[len(versions)-1].ContentMarkdown
|
||||||
|
}
|
||||||
|
return &FreeformDocDetail{
|
||||||
|
Document: *d,
|
||||||
|
Content: content,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
|
||||||
|
now := time.Now()
|
||||||
|
newDoc := &FreeformDocument{
|
||||||
|
DocID: docID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
OwnerUserID: userID,
|
||||||
|
SourceMessageID: &req.MessageID,
|
||||||
|
SourceContentSHA256: contentHash,
|
||||||
|
Title: title,
|
||||||
|
GovernanceMode: "freeform",
|
||||||
|
Space: "my_private",
|
||||||
|
CurrentVersionID: 1,
|
||||||
|
CurrentSHA256: contentHash,
|
||||||
|
IsDeleted: false,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
s.memDocs[docID] = newDoc
|
||||||
|
s.memVersions[docID] = []*FreeformDocumentVersion{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
DocID: docID,
|
||||||
|
VersionNo: 1,
|
||||||
|
ContentSHA256: contentHash,
|
||||||
|
ContentMarkdown: content,
|
||||||
|
CreatedBy: userID,
|
||||||
|
ChangeSummary: "从智能体回复保存",
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &FreeformDocDetail{
|
||||||
|
Document: *newDoc,
|
||||||
|
Content: content,
|
||||||
|
}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientKeyLookup(userID int, clientKey string) string {
|
||||||
|
return fmt.Sprintf("%d|%s", userID, strings.TrimSpace(clientKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) bootstrapLedger(ctx context.Context, userID int, detail *FreeformDocDetail) {
|
||||||
|
if s == nil || s.docRev == nil || detail == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
md := detail.Content
|
||||||
|
if strings.TrimSpace(md) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = s.docRev.EnsureBootstrap(ctx, docrevision.BootstrapRequest{
|
||||||
|
DocID: detail.Document.DocID,
|
||||||
|
Kind: "freeform",
|
||||||
|
Markdown: md,
|
||||||
|
ProjectID: int64(detail.Document.ProjectID),
|
||||||
|
LifecycleState: "drafting",
|
||||||
|
Actor: docrevision.ActorSnapshot{
|
||||||
|
DisplayName: "freeform-owner",
|
||||||
|
Role: "owner",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) findByClientKeyPG(ctx context.Context, userID int, clientKey string) (*FreeformDocDetail, error) {
|
||||||
|
var docID string
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT doc_id FROM freeform_documents
|
||||||
|
WHERE owner_user_id = $1 AND client_key = $2 AND is_deleted = FALSE
|
||||||
|
LIMIT 1
|
||||||
|
`, userID, clientKey).Scan(&docID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetDocument(ctx, userID, docID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDocument 直接创建自由文档(上传附件 / 自定义正文的服务端事实源)。
|
||||||
|
// client_key 持久化于 freeform_documents.client_key,与 owner 唯一;禁止污染 source_content_sha256。
|
||||||
|
// 创建后立即 EnsureBootstrap 版本账本,使 revision-state 可读、PATCH 可持久化。
|
||||||
|
func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFreeformDocRequest) (*FreeformDocDetail, error) {
|
||||||
|
title := strings.TrimSpace(req.Title)
|
||||||
|
content := req.ContentMarkdown
|
||||||
|
if title == "" {
|
||||||
|
return nil, fmt.Errorf("%w: title required", ErrInvalidParam)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: content_markdown required", ErrInvalidParam)
|
||||||
|
}
|
||||||
|
space := strings.TrimSpace(req.Space)
|
||||||
|
if space == "" {
|
||||||
|
space = "my_private"
|
||||||
|
}
|
||||||
|
// Direct create is upload/private registration only; project_shared must go through ShareDocument.
|
||||||
|
if space != "my_private" {
|
||||||
|
return nil, fmt.Errorf("%w: direct create only allows my_private; use share API", ErrInvalidParam)
|
||||||
|
}
|
||||||
|
projectID := req.ProjectID
|
||||||
|
if projectID <= 0 {
|
||||||
|
return nil, fmt.Errorf("%w: project_id required", ErrInvalidParam)
|
||||||
|
}
|
||||||
|
clientKey := strings.TrimSpace(req.ClientKey)
|
||||||
|
var clientKeyPtr *string
|
||||||
|
if clientKey != "" {
|
||||||
|
clientKeyPtr = &clientKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientKey != "" {
|
||||||
|
if s.pool != nil {
|
||||||
|
existing, err := s.findByClientKeyPG(ctx, userID, clientKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.mu.RLock()
|
||||||
|
existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if existingID != "" {
|
||||||
|
return s.GetDocument(ctx, userID, existingID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentHash := computeSHA256(content)
|
||||||
|
docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if s.pool != nil {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO freeform_documents (
|
||||||
|
doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||||
|
client_key, title, governance_mode, space, legacy_version_id, current_sha256,
|
||||||
|
is_deleted, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, FALSE, $8, $8)
|
||||||
|
`, docID, projectID, userID, contentHash, clientKeyPtr, title, space, now)
|
||||||
|
if err != nil {
|
||||||
|
// Unique race: another request inserted the same owner+client_key
|
||||||
|
if clientKey != "" {
|
||||||
|
if existing, findErr := s.findByClientKeyPG(ctx, userID, clientKey); findErr == nil && existing != nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("insert freeform_doc: %w", err)
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO freeform_document_versions (
|
||||||
|
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
|
||||||
|
) VALUES ($1, 1, $2, $3, $4, '上传附件注册', $5)
|
||||||
|
`, docID, contentHash, content, userID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert version: %w", err)
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
||||||
|
VALUES ($1, $2, 'create_upload', TRUE, $3)
|
||||||
|
`, docID, userID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert audit: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := &FreeformDocDetail{
|
||||||
|
Document: FreeformDocument{
|
||||||
|
DocID: docID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
OwnerUserID: userID,
|
||||||
|
SourceContentSHA256: contentHash,
|
||||||
|
ClientKey: clientKeyPtr,
|
||||||
|
Title: title,
|
||||||
|
GovernanceMode: "freeform",
|
||||||
|
Space: space,
|
||||||
|
CurrentVersionID: 1,
|
||||||
|
CurrentSHA256: contentHash,
|
||||||
|
IsDeleted: false,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
s.bootstrapLedger(ctx, userID, detail)
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if clientKey != "" {
|
||||||
|
if existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]; existingID != "" {
|
||||||
|
doc := s.memDocs[existingID]
|
||||||
|
versions := s.memVersions[existingID]
|
||||||
|
cur := ""
|
||||||
|
if len(versions) > 0 {
|
||||||
|
cur = versions[len(versions)-1].ContentMarkdown
|
||||||
|
}
|
||||||
|
if doc != nil {
|
||||||
|
return &FreeformDocDetail{Document: *doc, Content: cur}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newDoc := &FreeformDocument{
|
||||||
|
DocID: docID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
OwnerUserID: userID,
|
||||||
|
SourceContentSHA256: contentHash,
|
||||||
|
ClientKey: clientKeyPtr,
|
||||||
|
Title: title,
|
||||||
|
GovernanceMode: "freeform",
|
||||||
|
Space: space,
|
||||||
|
CurrentVersionID: 1,
|
||||||
|
CurrentSHA256: contentHash,
|
||||||
|
IsDeleted: false,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
s.memDocs[docID] = newDoc
|
||||||
|
s.memVersions[docID] = []*FreeformDocumentVersion{{
|
||||||
|
ID: 1, DocID: docID, VersionNo: 1, ContentSHA256: contentHash,
|
||||||
|
ContentMarkdown: content, CreatedBy: userID, ChangeSummary: "上传附件注册", CreatedAt: now,
|
||||||
|
}}
|
||||||
|
if clientKey != "" {
|
||||||
|
s.memClientKeys[clientKeyLookup(userID, clientKey)] = docID
|
||||||
|
}
|
||||||
|
detail := &FreeformDocDetail{Document: *newDoc, Content: content}
|
||||||
|
s.bootstrapLedger(ctx, userID, detail)
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package freedoc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*FreeformDocDetail, error) {
|
||||||
|
if docID == "" {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.pool != nil {
|
||||||
|
var doc FreeformDocument
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||||
|
title, governance_mode, space, legacy_version_id, current_sha256,
|
||||||
|
is_deleted, deleted_at, created_at, updated_at
|
||||||
|
FROM freeform_documents
|
||||||
|
WHERE doc_id = $1
|
||||||
|
`, docID)
|
||||||
|
if err := row.Scan(
|
||||||
|
&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID,
|
||||||
|
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
||||||
|
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
||||||
|
&doc.IsDeleted, &doc.DeletedAt, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if doc.IsDeleted {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 私密隔离红线:my_private 只有 owner 可读
|
||||||
|
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
||||||
|
_, _ = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO document_access_audit (doc_id, user_id, action, success)
|
||||||
|
VALUES ($1, $2, 'read_denied', FALSE)
|
||||||
|
`, docID, userID)
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT content_markdown FROM freeform_document_versions
|
||||||
|
WHERE doc_id = $1 AND version_no = $2
|
||||||
|
`, doc.DocID, doc.CurrentVersionID).Scan(&content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO document_access_audit (doc_id, user_id, action, success)
|
||||||
|
VALUES ($1, $2, 'read', TRUE)
|
||||||
|
`, docID, userID)
|
||||||
|
|
||||||
|
return &FreeformDocDetail{
|
||||||
|
Document: doc,
|
||||||
|
Content: content,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存模式
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
doc, ok := s.memDocs[docID]
|
||||||
|
if !ok || doc.IsDeleted {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
versions := s.memVersions[docID]
|
||||||
|
content := ""
|
||||||
|
for _, v := range versions {
|
||||||
|
if v.VersionNo == doc.CurrentVersionID {
|
||||||
|
content = v.ContentMarkdown
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FreeformDocDetail{
|
||||||
|
Document: *doc,
|
||||||
|
Content: content,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDocuments 按空间与权限列出自由文档概要
|
||||||
|
|
||||||
|
func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int64, space string) ([]FreeformDocSummary, error) {
|
||||||
|
if space != "my_private" && space != "project_shared" && space != "trash" {
|
||||||
|
space = "my_private"
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.pool != nil {
|
||||||
|
var rows pgx.Rows
|
||||||
|
var err error
|
||||||
|
if space == "my_private" {
|
||||||
|
// 我的私密:属于当前用户
|
||||||
|
if projectID > 0 {
|
||||||
|
rows, err = s.pool.Query(ctx, `
|
||||||
|
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
||||||
|
FROM freeform_documents
|
||||||
|
WHERE owner_user_id = $1 AND space = 'my_private' AND project_id = $2 AND is_deleted = FALSE
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
`, userID, projectID)
|
||||||
|
} else {
|
||||||
|
rows, err = s.pool.Query(ctx, `
|
||||||
|
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
||||||
|
FROM freeform_documents
|
||||||
|
WHERE owner_user_id = $1 AND space = 'my_private' AND is_deleted = FALSE
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
`, userID)
|
||||||
|
}
|
||||||
|
} else if space == "project_shared" {
|
||||||
|
// 项目共享参考:属于当前项目的所有公开参考
|
||||||
|
rows, err = s.pool.Query(ctx, `
|
||||||
|
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
||||||
|
FROM freeform_documents
|
||||||
|
WHERE project_id = $1 AND space = 'project_shared' AND is_deleted = FALSE
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
`, projectID)
|
||||||
|
} else {
|
||||||
|
// 回收站:当前用户删除的
|
||||||
|
rows, err = s.pool.Query(ctx, `
|
||||||
|
SELECT doc_id, project_id, owner_user_id, title, space, legacy_version_id, current_sha256, created_at, updated_at
|
||||||
|
FROM freeform_documents
|
||||||
|
WHERE owner_user_id = $1 AND is_deleted = TRUE
|
||||||
|
ORDER BY deleted_at DESC
|
||||||
|
`, userID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []FreeformDocSummary
|
||||||
|
for rows.Next() {
|
||||||
|
var sm FreeformDocSummary
|
||||||
|
if err := rows.Scan(
|
||||||
|
&sm.DocID, &sm.ProjectID, &sm.OwnerUserID, &sm.Title, &sm.Space,
|
||||||
|
&sm.CurrentVersionID, &sm.CurrentSHA256, &sm.CreatedAt, &sm.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, sm)
|
||||||
|
}
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存模式
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var results []FreeformDocSummary
|
||||||
|
for _, d := range s.memDocs {
|
||||||
|
if space == "my_private" {
|
||||||
|
if d.OwnerUserID == userID && d.Space == "my_private" && !d.IsDeleted {
|
||||||
|
if projectID <= 0 || d.ProjectID == projectID {
|
||||||
|
results = append(results, FreeformDocSummary{
|
||||||
|
DocID: d.DocID,
|
||||||
|
ProjectID: d.ProjectID,
|
||||||
|
OwnerUserID: d.OwnerUserID,
|
||||||
|
Title: d.Title,
|
||||||
|
Space: d.Space,
|
||||||
|
CurrentVersionID: d.CurrentVersionID,
|
||||||
|
CurrentSHA256: d.CurrentSHA256,
|
||||||
|
CreatedAt: d.CreatedAt,
|
||||||
|
UpdatedAt: d.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if space == "project_shared" {
|
||||||
|
if d.ProjectID == projectID && d.Space == "project_shared" && !d.IsDeleted {
|
||||||
|
results = append(results, FreeformDocSummary{
|
||||||
|
DocID: d.DocID,
|
||||||
|
ProjectID: d.ProjectID,
|
||||||
|
OwnerUserID: d.OwnerUserID,
|
||||||
|
Title: d.Title,
|
||||||
|
Space: d.Space,
|
||||||
|
CurrentVersionID: d.CurrentVersionID,
|
||||||
|
CurrentSHA256: d.CurrentSHA256,
|
||||||
|
CreatedAt: d.CreatedAt,
|
||||||
|
UpdatedAt: d.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDocument 更新文档正文或标题,支持 If-Match 乐观并发控制
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package freedoc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShareDocument 将私密文档共享至项目参考库(仅 owner 可操作)
|
||||||
|
func (s *Service) ShareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) {
|
||||||
|
if docID == "" {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.pool != nil {
|
||||||
|
var doc FreeformDocument
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT doc_id, project_id, owner_user_id, space, is_deleted
|
||||||
|
FROM freeform_documents WHERE doc_id = $1
|
||||||
|
`, docID)
|
||||||
|
if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if doc.IsDeleted {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if doc.OwnerUserID != userID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE freeform_documents
|
||||||
|
SET space = 'project_shared', updated_at = $1
|
||||||
|
WHERE doc_id = $2
|
||||||
|
`, now, docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
||||||
|
VALUES ($1, $2, 'share', TRUE, $3)
|
||||||
|
`, docID, userID, now)
|
||||||
|
|
||||||
|
doc.Space = "project_shared"
|
||||||
|
doc.UpdatedAt = now
|
||||||
|
return &doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存模式
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
doc, ok := s.memDocs[docID]
|
||||||
|
if !ok || doc.IsDeleted {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if doc.OwnerUserID != userID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
doc.Space = "project_shared"
|
||||||
|
doc.UpdatedAt = time.Now()
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnshareDocument 撤回共享,恢复为个人私密
|
||||||
|
func (s *Service) UnshareDocument(ctx context.Context, userID int, docID string) (*FreeformDocument, error) {
|
||||||
|
if docID == "" {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.pool != nil {
|
||||||
|
var doc FreeformDocument
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT doc_id, project_id, owner_user_id, space, is_deleted
|
||||||
|
FROM freeform_documents WHERE doc_id = $1
|
||||||
|
`, docID)
|
||||||
|
if err := row.Scan(&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.Space, &doc.IsDeleted); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if doc.IsDeleted {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if doc.OwnerUserID != userID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE freeform_documents
|
||||||
|
SET space = 'my_private', updated_at = $1
|
||||||
|
WHERE doc_id = $2
|
||||||
|
`, now, docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
||||||
|
VALUES ($1, $2, 'unshare', TRUE, $3)
|
||||||
|
`, docID, userID, now)
|
||||||
|
|
||||||
|
doc.Space = "my_private"
|
||||||
|
doc.UpdatedAt = now
|
||||||
|
return &doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存模式
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
doc, ok := s.memDocs[docID]
|
||||||
|
if !ok || doc.IsDeleted {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if doc.OwnerUserID != userID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
doc.Space = "my_private"
|
||||||
|
doc.UpdatedAt = time.Now()
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDocument 软删除文档(仅 owner 可操作)
|
||||||
|
func (s *Service) DeleteDocument(ctx context.Context, userID int, docID string) error {
|
||||||
|
if docID == "" {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.pool != nil {
|
||||||
|
var doc FreeformDocument
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT doc_id, owner_user_id, is_deleted
|
||||||
|
FROM freeform_documents WHERE doc_id = $1
|
||||||
|
`, docID)
|
||||||
|
if err := row.Scan(&doc.DocID, &doc.OwnerUserID, &doc.IsDeleted); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if doc.IsDeleted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if doc.OwnerUserID != userID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE freeform_documents
|
||||||
|
SET is_deleted = TRUE, deleted_at = $1, space = 'trash'
|
||||||
|
WHERE doc_id = $2
|
||||||
|
`, now, docID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
||||||
|
VALUES ($1, $2, 'delete', TRUE, $3)
|
||||||
|
`, docID, userID, now)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存模式
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
doc, ok := s.memDocs[docID]
|
||||||
|
if !ok || doc.IsDeleted {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if doc.OwnerUserID != userID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
doc.IsDeleted = true
|
||||||
|
now := time.Now()
|
||||||
|
doc.DeletedAt = &now
|
||||||
|
doc.Space = "trash"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package freedoc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/docrevision"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateDocument 更新文档正文或标题,支持 If-Match 乐观并发控制
|
||||||
|
func (s *Service) UpdateDocument(ctx context.Context, userID int, docID string, req UpdateFreeformDocRequest) (*FreeformDocDetail, error) {
|
||||||
|
if docID == "" {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.pool != nil {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var doc FreeformDocument
|
||||||
|
row := tx.QueryRow(ctx, `
|
||||||
|
SELECT doc_id, project_id, owner_user_id, source_message_id, source_content_sha256,
|
||||||
|
title, governance_mode, space, legacy_version_id, current_sha256,
|
||||||
|
is_deleted, created_at, updated_at
|
||||||
|
FROM freeform_documents
|
||||||
|
WHERE doc_id = $1 FOR UPDATE
|
||||||
|
`, docID)
|
||||||
|
if err := row.Scan(
|
||||||
|
&doc.DocID, &doc.ProjectID, &doc.OwnerUserID, &doc.SourceMessageID,
|
||||||
|
&doc.SourceContentSHA256, &doc.Title, &doc.GovernanceMode,
|
||||||
|
&doc.Space, &doc.CurrentVersionID, &doc.CurrentSHA256,
|
||||||
|
&doc.IsDeleted, &doc.CreatedAt, &doc.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if doc.IsDeleted {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 私密文档非本人严禁修改
|
||||||
|
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// 乐观并发冲突检验
|
||||||
|
if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID {
|
||||||
|
return nil, ErrConflict
|
||||||
|
}
|
||||||
|
if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 {
|
||||||
|
return nil, ErrConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
newTitle := doc.Title
|
||||||
|
if req.Title != nil && *req.Title != "" {
|
||||||
|
newTitle = *req.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
curContent := ""
|
||||||
|
_ = tx.QueryRow(ctx, `SELECT content_markdown FROM freeform_document_versions WHERE doc_id = $1 AND version_no = $2`, doc.DocID, doc.CurrentVersionID).Scan(&curContent)
|
||||||
|
|
||||||
|
newContent := curContent
|
||||||
|
if req.ContentMarkdown != nil {
|
||||||
|
newContent = *req.ContentMarkdown
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash := computeSHA256(newContent)
|
||||||
|
if newHash != doc.CurrentSHA256 {
|
||||||
|
// 内容变更,生成新版本
|
||||||
|
nextVersion := doc.CurrentVersionID + 1
|
||||||
|
summary := req.ChangeSummary
|
||||||
|
if summary == "" {
|
||||||
|
summary = "编辑修改正文"
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO freeform_document_versions (
|
||||||
|
doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, doc.DocID, nextVersion, newHash, newContent, userID, summary, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE freeform_documents
|
||||||
|
SET title = $1, legacy_version_id = $2, current_sha256 = $3, updated_at = $4
|
||||||
|
WHERE doc_id = $5
|
||||||
|
`, newTitle, nextVersion, newHash, now, doc.DocID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update doc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.CurrentVersionID = nextVersion
|
||||||
|
doc.CurrentSHA256 = newHash
|
||||||
|
} else if newTitle != doc.Title {
|
||||||
|
// 仅标题变更
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE freeform_documents
|
||||||
|
SET title = $1, updated_at = $2
|
||||||
|
WHERE doc_id = $3
|
||||||
|
`, newTitle, now, doc.DocID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update doc title: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.Title = newTitle
|
||||||
|
doc.UpdatedAt = now
|
||||||
|
|
||||||
|
_, _ = tx.Exec(ctx, `
|
||||||
|
INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at)
|
||||||
|
VALUES ($1, $2, 'update', TRUE, $3)
|
||||||
|
`, doc.DocID, userID, now)
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FreeformDocDetail{
|
||||||
|
Document: doc,
|
||||||
|
Content: newContent,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存模式
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
doc, ok := s.memDocs[docID]
|
||||||
|
if !ok || doc.IsDeleted {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if doc.Space == "my_private" && doc.OwnerUserID != userID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
if req.ExpectedVersion != nil && *req.ExpectedVersion != doc.CurrentVersionID {
|
||||||
|
return nil, ErrConflict
|
||||||
|
}
|
||||||
|
if req.ExpectedSHA256 != nil && *req.ExpectedSHA256 != "" && *req.ExpectedSHA256 != doc.CurrentSHA256 {
|
||||||
|
return nil, ErrConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
newTitle := doc.Title
|
||||||
|
if req.Title != nil && *req.Title != "" {
|
||||||
|
newTitle = *req.Title
|
||||||
|
}
|
||||||
|
doc.Title = newTitle
|
||||||
|
|
||||||
|
curContent := ""
|
||||||
|
versions := s.memVersions[docID]
|
||||||
|
if len(versions) > 0 {
|
||||||
|
curContent = versions[len(versions)-1].ContentMarkdown
|
||||||
|
}
|
||||||
|
|
||||||
|
newContent := curContent
|
||||||
|
if req.ContentMarkdown != nil {
|
||||||
|
newContent = *req.ContentMarkdown
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash := computeSHA256(newContent)
|
||||||
|
if newHash != doc.CurrentSHA256 {
|
||||||
|
nextVersion := doc.CurrentVersionID + 1
|
||||||
|
doc.CurrentVersionID = nextVersion
|
||||||
|
doc.CurrentSHA256 = newHash
|
||||||
|
s.memVersions[docID] = append(s.memVersions[docID], &FreeformDocumentVersion{
|
||||||
|
ID: int64(len(s.memVersions[docID]) + 1),
|
||||||
|
DocID: docID,
|
||||||
|
VersionNo: nextVersion,
|
||||||
|
ContentSHA256: newHash,
|
||||||
|
ContentMarkdown: newContent,
|
||||||
|
CreatedBy: userID,
|
||||||
|
ChangeSummary: req.ChangeSummary,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
doc.UpdatedAt = now
|
||||||
|
detail := &FreeformDocDetail{
|
||||||
|
Document: *doc,
|
||||||
|
Content: newContent,
|
||||||
|
}
|
||||||
|
s.syncLedgerLocked(ctx, userID, detail)
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncLedgerLocked imports legacy checkpoints then records human revisions on the shared ledger.
|
||||||
|
func (s *Service) syncLedgerLocked(ctx context.Context, userID int, detail *FreeformDocDetail) {
|
||||||
|
if s.docRev == nil || detail == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc := detail.Document
|
||||||
|
checkpoints := make([]struct {
|
||||||
|
VersionNo int
|
||||||
|
Markdown string
|
||||||
|
SHA string
|
||||||
|
}, 0, len(s.memVersions[doc.DocID]))
|
||||||
|
for _, v := range s.memVersions[doc.DocID] {
|
||||||
|
checkpoints = append(checkpoints, struct {
|
||||||
|
VersionNo int
|
||||||
|
Markdown string
|
||||||
|
SHA string
|
||||||
|
}{VersionNo: v.VersionNo, Markdown: v.ContentMarkdown, SHA: v.ContentSHA256})
|
||||||
|
}
|
||||||
|
prior := checkpoints
|
||||||
|
if len(prior) > 0 {
|
||||||
|
prior = prior[:len(prior)-1]
|
||||||
|
}
|
||||||
|
st, err := s.docRev.ImportFreeformLegacy(ctx, doc.DocID, int64(userID), int64(doc.ProjectID), prior, detail.Content)
|
||||||
|
if err != nil || st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = s.docRev.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{
|
||||||
|
DocID: doc.DocID, Markdown: detail.Content,
|
||||||
|
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
|
||||||
|
IdempotencyKey: fmt.Sprintf("freeform:%s:%s", doc.DocID, detail.Document.CurrentSHA256),
|
||||||
|
ActorUserID: int64(userID),
|
||||||
|
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "freeform-owner", Role: "owner"},
|
||||||
|
LifecycleState: "drafting",
|
||||||
|
ProjectID: int64(doc.ProjectID),
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
|||||||
|
package intent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// matchExpectationHit 检验是否命中挂起预期 (分支 1)
|
||||||
|
func matchExpectationHit(input string, pending *PendingExpectation) *CandidateIntent {
|
||||||
|
// 0. 检验超时过期
|
||||||
|
if !pending.ExpiresAt.IsZero() && time.Now().After(pending.ExpiresAt) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 否定/暂缓意图
|
||||||
|
declineKeywords := []string{"先别", "别推", "算了", "先不", "等等", "暂时不", "先别推", "不要推进", "不要", "不行", "算了吧", "先放一放", "先等等", "取消", "放弃"}
|
||||||
|
if pending.DeclineWord != "" {
|
||||||
|
declineKeywords = append(declineKeywords, pending.DeclineWord)
|
||||||
|
}
|
||||||
|
for _, dk := range declineKeywords {
|
||||||
|
if input == dk || strings.HasPrefix(input, dk) {
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentDeclineExpectation,
|
||||||
|
Confidence: 0.95,
|
||||||
|
Branch: BranchExpectationHit,
|
||||||
|
IsDecline: true,
|
||||||
|
FilledSlots: make(map[string]string),
|
||||||
|
Reasoning: fmt.Sprintf("用户明确表示暂缓或取消当前挂起建议 ('%s')", input),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 选择序号检验 (如 "1", "2", "选1", "选2", "第1个", "第一个", "选项1")
|
||||||
|
var matchedOpt *ExpectationOption
|
||||||
|
optIndex := matchOptionIndex(input, pending.Options)
|
||||||
|
if optIndex >= 0 && optIndex < len(pending.Options) {
|
||||||
|
matchedOpt = &pending.Options[optIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 肯定前缀后含独立新请求谓词时,不得吞掉新意图(如「好,请先查法规」)
|
||||||
|
if matchedOpt == nil && hasIndependentNewPredicate(input) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 显式确认短语(不受 AllowAffirmative 限制)
|
||||||
|
explicitConfirm := isExplicitConfirmPhrase(input)
|
||||||
|
|
||||||
|
// 4. 肯定词检验 (如 "好", "行", "可以", "成", "没问题", "同意", "好的", "就按这个", "按你说的办", "确认")
|
||||||
|
affirmativePrefixes := []string{"好", "好的", "行", "行啊", "成", "可以", "没问题", "同意", "就按这个", "按这个", "就按这个办", "听你的", "ok", "yes", "就这么定", "确认", "确定", "批准"}
|
||||||
|
if pending.AffirmativeWord != "" {
|
||||||
|
affirmativePrefixes = append(affirmativePrefixes, pending.AffirmativeWord)
|
||||||
|
}
|
||||||
|
isAffirmative := explicitConfirm
|
||||||
|
|
||||||
|
for _, aff := range affirmativePrefixes {
|
||||||
|
if input == aff ||
|
||||||
|
strings.HasPrefix(input, aff+",") ||
|
||||||
|
strings.HasPrefix(input, aff+",") ||
|
||||||
|
strings.HasPrefix(input, aff+"但") ||
|
||||||
|
strings.HasPrefix(input, aff+" ") ||
|
||||||
|
strings.HasPrefix(input, aff+"!") ||
|
||||||
|
strings.HasPrefix(input, aff+"!") ||
|
||||||
|
(aff == "确认" && (input == "确认" || input == "确认驳回" || input == "确认批准" || input == "确认执行")) ||
|
||||||
|
(pending.AffirmativeWord != "" && strings.HasPrefix(input, pending.AffirmativeWord)) {
|
||||||
|
isAffirmative = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 裸肯定词必须 AllowAffirmative=true(显式确认短语除外)
|
||||||
|
if isAffirmative && !explicitConfirm && !pending.AllowAffirmative {
|
||||||
|
bareAffirmative := false
|
||||||
|
for _, aff := range affirmativePrefixes {
|
||||||
|
if input == aff {
|
||||||
|
bareAffirmative = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bareAffirmative {
|
||||||
|
isAffirmative = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchedOpt == nil && isAffirmative {
|
||||||
|
if pending.DefaultOption != nil {
|
||||||
|
matchedOpt = pending.DefaultOption
|
||||||
|
} else if len(pending.Options) > 0 {
|
||||||
|
matchedOpt = &pending.Options[0]
|
||||||
|
} else if pending.ExpectedIntent != "" {
|
||||||
|
matchedOpt = &ExpectationOption{
|
||||||
|
OptionID: pending.TargetAction,
|
||||||
|
Title: pending.Prompt,
|
||||||
|
Intent: pending.ExpectedIntent,
|
||||||
|
Slots: pending.Payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchedOpt == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 提取增量事实补丁 (Patch Merge)
|
||||||
|
// 例如: "好,但目标市场改成欧洲CE", "选1,先把预算定为15万"
|
||||||
|
patchSlots := extractFactSlots(input)
|
||||||
|
|
||||||
|
filledSlots := make(map[string]string)
|
||||||
|
for k, v := range matchedOpt.Slots {
|
||||||
|
filledSlots[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range patchSlots {
|
||||||
|
filledSlots[k] = v
|
||||||
|
}
|
||||||
|
if pending.TargetAction != "" && filledSlots["target_action"] == "" {
|
||||||
|
filledSlots["target_action"] = pending.TargetAction
|
||||||
|
}
|
||||||
|
|
||||||
|
confidence := 1.0
|
||||||
|
reasoning := fmt.Sprintf("用户明确肯定上一轮参谋建议动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title)
|
||||||
|
if len(patchSlots) > 0 {
|
||||||
|
confidence = 0.98
|
||||||
|
reasoning = fmt.Sprintf("用户肯定上一轮建议动作 [%s: %s],并附带增量事实补丁: %v", matchedOpt.OptionID, matchedOpt.Title, patchSlots)
|
||||||
|
} else if optIndex >= 0 {
|
||||||
|
confidence = 0.98
|
||||||
|
reasoning = fmt.Sprintf("用户按序号选择了备选动作 [%s: %s]", matchedOpt.OptionID, matchedOpt.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: matchedOpt.Intent,
|
||||||
|
Confidence: confidence,
|
||||||
|
Branch: BranchExpectationHit,
|
||||||
|
FilledSlots: filledSlots,
|
||||||
|
PatchSlots: patchSlots,
|
||||||
|
MatchedOption: matchedOpt,
|
||||||
|
Reasoning: reasoning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchOptionIndex 匹配用户输入的选项编号
|
||||||
|
func matchOptionIndex(input string, options []ExpectationOption) int {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if len(options) == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns := []struct {
|
||||||
|
regex string
|
||||||
|
idx int
|
||||||
|
}{
|
||||||
|
{"^(1|选1|选第1个|第一个|第1个|选项1|选项一)$", 0},
|
||||||
|
{"^(2|选2|选第2个|第二个|第2个|选项2|选项二)$", 1},
|
||||||
|
{"^(3|选3|选第3个|第三个|第3个|选项3|选项三)$", 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range patterns {
|
||||||
|
if matched, _ := regexp.MatchString("(?i)"+p.regex, input); matched {
|
||||||
|
return p.idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, opt := range options {
|
||||||
|
if strings.EqualFold(opt.OptionID, input) {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// isConsultativeOrHesitant 检验是否属于犹豫或征询参谋意见 (分支 3)
|
||||||
|
func isConsultativeOrHesitant(input string) bool {
|
||||||
|
patterns := []string{
|
||||||
|
"你觉得呢", "你怎么看", "有什么建议", "怎么选", "拿不定主意",
|
||||||
|
"先放放.*你觉得", "这个先放放", "先放一放.*你觉得", "哪个好", "给点意见",
|
||||||
|
}
|
||||||
|
for _, p := range patterns {
|
||||||
|
if matched, _ := regexp.MatchString("(?i)"+p, input); matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAmbiguousConsultResult 构建分支 3 (模糊/分忧引导) 输出
|
||||||
|
func buildAmbiguousConsultResult(input, snapID string, pending *PendingExpectation) *IntentRecognitionResult {
|
||||||
|
clarifyQ := "我为您准备了当前阶段最稳妥的推进选项。请问需要我为您详细分析技术可行性,还是先为您做方案摸底?"
|
||||||
|
if pending != nil && len(pending.Options) > 0 {
|
||||||
|
var optDesc strings.Builder
|
||||||
|
optDesc.WriteString("针对目前的决策节点,我为您梳理了以下选项供您参考:\n")
|
||||||
|
for _, o := range pending.Options {
|
||||||
|
optDesc.WriteString(fmt.Sprintf("• [%s] %s: %s\n", o.OptionID, o.Title, o.Description))
|
||||||
|
}
|
||||||
|
clarifyQ = optDesc.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
top := &CandidateIntent{
|
||||||
|
Intent: IntentCasualChat,
|
||||||
|
Confidence: 0.65,
|
||||||
|
Branch: BranchAmbiguousFallback,
|
||||||
|
FilledSlots: make(map[string]string),
|
||||||
|
Reasoning: "用户表达犹豫或主动征询参谋意见,触发参谋主动分忧与引导",
|
||||||
|
ClarificationQuestion: clarifyQ,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &IntentRecognitionResult{
|
||||||
|
RawInput: input,
|
||||||
|
Branch: BranchAmbiguousFallback,
|
||||||
|
TopIntent: top,
|
||||||
|
Candidates: []CandidateIntent{*top},
|
||||||
|
ContextSnapshotID: snapID,
|
||||||
|
RequiresClarifying: true,
|
||||||
|
}
|
||||||
|
finalizeRecognitionResult(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package intent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isSecurityViolation 检查是否命中敏感安全与权限红线
|
||||||
|
func isSecurityViolation(input string) bool {
|
||||||
|
lower := strings.ToLower(input)
|
||||||
|
patterns := []string{
|
||||||
|
"修改身份", "身份修改", "修改职位", "职位修改", "修改岗位", "岗位修改",
|
||||||
|
"修改姓名", "姓名修改", "修改名字", "名字修改",
|
||||||
|
"修改部门", "部门修改",
|
||||||
|
"修改资质", "资质修改",
|
||||||
|
"改成批准人", "改成审核人", "改成编制人",
|
||||||
|
"工号改成", "修改工号", "工号修改",
|
||||||
|
"修改设置", "修改配置", "系统设置", "系统配置",
|
||||||
|
"修改网关", "网关配置", "网关密钥", "大模型网关", "模型网关",
|
||||||
|
"修改密码", "重置密码", "提升权限", "越权",
|
||||||
|
"修改向量库", "向量库连接", "向量库配置", "修改向量库连接",
|
||||||
|
"修改知识库连接", "知识库连接", "知识库配置",
|
||||||
|
}
|
||||||
|
for _, p := range patterns {
|
||||||
|
if strings.Contains(lower, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
secRegexes := []string{
|
||||||
|
`把.*(?:身份|职位|角色|工号|权限|姓名|名字|部门|资质).*(?:修改|改|换|重置|设为|变为)`,
|
||||||
|
`(?:修改|改|换|重置|设为).*(?:我的)?(?:身份|职位|角色|工号|权限|姓名|名字|部门|资质|网关|向量库|知识库连接|密钥|apikey|api_key)`,
|
||||||
|
`.*(?:向量库连接|修改向量库|知识库连接|大模型网关密钥|网关密钥).*`,
|
||||||
|
}
|
||||||
|
for _, r := range secRegexes {
|
||||||
|
if matched, _ := regexp.MatchString(r, input); matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPureUICommand 检查是否为纯前端UI视窗布局与主题控制指令
|
||||||
|
func isPureUICommand(input string) bool {
|
||||||
|
patterns := []string{
|
||||||
|
"打开工具栏", "关闭工具栏",
|
||||||
|
"折叠侧边栏", "展开侧边栏", "隐藏侧边栏", "显示侧边栏",
|
||||||
|
"折叠左侧栏", "展开左侧栏", "折叠右侧栏", "展开右侧栏",
|
||||||
|
"折叠工作台", "展开工作台",
|
||||||
|
"切换全屏", "全屏显示", "退出全屏",
|
||||||
|
"打开控制台", "关闭控制台",
|
||||||
|
"切换主题", "换成深色", "换成浅色",
|
||||||
|
"打开抽屉", "展开抽屉", "关闭抽屉",
|
||||||
|
"深色模式", "浅色模式", "深色主题", "浅色主题",
|
||||||
|
}
|
||||||
|
for _, p := range patterns {
|
||||||
|
if strings.Contains(input, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTargetDoc 优先从输入中提取显式受控文档名称,提取不到时回退至上下文的 ActiveDocID
|
||||||
|
func resolveTargetDoc(input string, userCtx *ContextSnapshot) string {
|
||||||
|
if doc := extractDocName(input); doc != "" {
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
if userCtx != nil && userCtx.ActiveDocID != "" {
|
||||||
|
return userCtx.ActiveDocID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRejectReasonQuery 判定是否为询问驳回/退回原因(而非发起驳回执行)
|
||||||
|
func isRejectReasonQuery(input string) bool {
|
||||||
|
markers := []string{
|
||||||
|
"原因", "为何", "为什么", "怎么回事", "什么情况", "什么意见",
|
||||||
|
"哪点", "哪些问题", "整改", "意见是什么", "理由", "怎么改", "如何修改",
|
||||||
|
"查一下驳回", "看看驳回",
|
||||||
|
}
|
||||||
|
for _, m := range markers {
|
||||||
|
if strings.Contains(input, m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Contains(input, "被驳回")
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFactSlots 从文本中提取结构化事实槽位 (支持补丁合并与补充信息)
|
||||||
|
func extractFactSlots(input string) map[string]string {
|
||||||
|
slots := make(map[string]string)
|
||||||
|
|
||||||
|
marketPrefixes := []string{
|
||||||
|
"目标市场不仅是", "目标市场不仅有", "目标市场还包括", "目标市场确定为", "目标市场确定是",
|
||||||
|
"目标市场改成", "目标市场改为", "目标市场是", "目标市场为",
|
||||||
|
"市场改成", "市场改为", "市场是", "市场为", "目标市场除了国内还想考虑",
|
||||||
|
}
|
||||||
|
for _, p := range marketPrefixes {
|
||||||
|
if idx := strings.Index(input, p); idx != -1 {
|
||||||
|
val := strings.TrimSpace(input[idx+len(p):])
|
||||||
|
val = strings.Trim(val, "::,,。 ")
|
||||||
|
if val != "" {
|
||||||
|
slots["target_market"] = val
|
||||||
|
slots[SlotFactKey] = "target_market"
|
||||||
|
slots[SlotFactValue] = val
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetPrefixes := []string{"预算改成", "预算改为", "预算是", "预算为", "预算只有", "预算大概", "预算"}
|
||||||
|
for _, p := range budgetPrefixes {
|
||||||
|
if idx := strings.Index(input, p); idx != -1 && slots["budget"] == "" {
|
||||||
|
val := strings.TrimSpace(input[idx+len(p):])
|
||||||
|
val = strings.Trim(val, "::,,。 ")
|
||||||
|
if val != "" {
|
||||||
|
slots["budget"] = val
|
||||||
|
slots[SlotFactKey] = "budget"
|
||||||
|
slots[SlotFactValue] = val
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usePrefixes := []string{"用途是", "用途为", "预期用途是", "场景是", "场景为"}
|
||||||
|
for _, p := range usePrefixes {
|
||||||
|
if idx := strings.Index(input, p); idx != -1 && slots["intended_use"] == "" {
|
||||||
|
val := strings.TrimSpace(input[idx+len(p):])
|
||||||
|
val = strings.Trim(val, "::,,。 ")
|
||||||
|
if val != "" {
|
||||||
|
slots["intended_use"] = val
|
||||||
|
slots[SlotFactKey] = "intended_use"
|
||||||
|
slots[SlotFactValue] = val
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGreetingOrHelp(input string) bool {
|
||||||
|
greetings := []string{"你好", "您好", "早上好", "下午好", "晚上好", "早啊", "在吗", "hi", "hello", "你是谁"}
|
||||||
|
for _, g := range greetings {
|
||||||
|
if strings.Contains(strings.ToLower(input), g) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortCandidates(c []CandidateIntent) {
|
||||||
|
for i := 0; i < len(c)-1; i++ {
|
||||||
|
for j := i + 1; j < len(c); j++ {
|
||||||
|
if c[j].Confidence > c[i].Confidence {
|
||||||
|
c[i], c[j] = c[j], c[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package intent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// matchDocGovAction 匹配法定受控文档治理流转动作与槽位提取
|
||||||
|
func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent {
|
||||||
|
// 0. 询问驳回原因:归属 DECISION_QUERY,不得路由为 DOCGOV_*_REJECT
|
||||||
|
if (strings.Contains(input, "驳回") || strings.Contains(input, "退回")) && isRejectReasonQuery(input) {
|
||||||
|
slots := make(map[string]string)
|
||||||
|
fillTargetDocSlot(slots, input, userCtx)
|
||||||
|
if q := scopeMismatchClarify(slots, activeWorkflowKeyOf(userCtx)); q != "" {
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentQueryRejectReason,
|
||||||
|
Confidence: 0.96,
|
||||||
|
Branch: BranchNewIntentPreempt,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: "用户询问的受控文档属于非激活工作流,前置剪枝为只读询问并给出切换引导",
|
||||||
|
ClarificationQuestion: q,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentQueryRejectReason,
|
||||||
|
Confidence: 0.96,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: "用户询问受控文档驳回/退回原因,路由为 QUERY_REJECT_REASON",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨工作流作用域前置剪枝:治理写动作显式点名他流文档时,拦截并引导切换,不落入任何 DOCGOV_* 写意图。
|
||||||
|
if cand := docGovScopePreempt(input, userCtx); cand != nil {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 驳回 (Review Reject or Final Reject)
|
||||||
|
if strings.Contains(input, "驳回") {
|
||||||
|
isFinal := false
|
||||||
|
if strings.Contains(input, "终审") || strings.Contains(input, "批准") {
|
||||||
|
isFinal = true
|
||||||
|
} else if userCtx != nil && (userCtx.DocLifecycleState == "approving" || userCtx.UserDocRole == "approver") {
|
||||||
|
isFinal = true
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIntent := IntentDocGovReviewReject
|
||||||
|
if isFinal {
|
||||||
|
targetIntent = IntentDocGovFinalReject
|
||||||
|
}
|
||||||
|
|
||||||
|
slots := make(map[string]string)
|
||||||
|
|
||||||
|
// target_doc
|
||||||
|
targetDoc := resolveTargetDoc(input, userCtx)
|
||||||
|
if targetDoc != "" {
|
||||||
|
slots[SlotTargetDoc] = targetDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
// target_section
|
||||||
|
section := extractSection(input)
|
||||||
|
if section != "" {
|
||||||
|
slots[SlotTargetSection] = section
|
||||||
|
}
|
||||||
|
|
||||||
|
// reject_reason
|
||||||
|
reason := extractRejectReason(input)
|
||||||
|
if reason != "" {
|
||||||
|
slots[SlotRejectReason] = reason
|
||||||
|
}
|
||||||
|
|
||||||
|
confidence := 0.95
|
||||||
|
reasoning := fmt.Sprintf("用户下达法定治理驳回指令,目标意图为 %s", targetIntent)
|
||||||
|
clarifyQ := ""
|
||||||
|
|
||||||
|
// Stage 0 权限与状态前置剪枝校验
|
||||||
|
if userCtx != nil {
|
||||||
|
if !isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" {
|
||||||
|
confidence = 0.90
|
||||||
|
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是审核人(UserDocRole: %s),权限不符,无法执行审核驳回", userCtx.UserDocRole)
|
||||||
|
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备审核驳回权限(需审核人角色)。", userCtx.UserDocRole)
|
||||||
|
} else if isFinal && userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" {
|
||||||
|
confidence = 0.90
|
||||||
|
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审驳回", userCtx.UserDocRole)
|
||||||
|
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准驳回权限(需批准人角色)。", userCtx.UserDocRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
if userCtx.DocLifecycleState == "drafting" {
|
||||||
|
confidence = 0.90
|
||||||
|
reasoning = "前置剪枝:当前文档处于编制草稿阶段(drafting),尚未提交审核,无法执行驳回操作"
|
||||||
|
clarifyQ = "当前文档处于编制中(drafting),尚未送审,无需驳回。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: targetIntent,
|
||||||
|
Confidence: confidence,
|
||||||
|
Branch: BranchNewIntentPreempt,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: reasoning,
|
||||||
|
ClarificationQuestion: clarifyQ,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 终审批准 (Final Approve)
|
||||||
|
if strings.Contains(input, "终审批准") || strings.Contains(input, "批准发布") || strings.Contains(input, "批准生效") ||
|
||||||
|
strings.Contains(input, "同意发布") || (strings.Contains(input, "批准") && !strings.Contains(input, "不")) {
|
||||||
|
targetIntent := IntentDocGovFinalApprove
|
||||||
|
slots := make(map[string]string)
|
||||||
|
targetDoc := resolveTargetDoc(input, userCtx)
|
||||||
|
if targetDoc != "" {
|
||||||
|
slots[SlotTargetDoc] = targetDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
confidence := 0.95
|
||||||
|
reasoning := "用户下达受控文档终审批准生效指令"
|
||||||
|
clarifyQ := ""
|
||||||
|
|
||||||
|
if userCtx != nil {
|
||||||
|
if userCtx.UserDocRole != "" && userCtx.UserDocRole != "approver" && userCtx.UserDocRole != "system" {
|
||||||
|
confidence = 0.90
|
||||||
|
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是批准人(UserDocRole: %s),权限不符,无法执行终审批准", userCtx.UserDocRole)
|
||||||
|
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备终审批准权限(需批准人角色)。", userCtx.UserDocRole)
|
||||||
|
} else if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "approving" {
|
||||||
|
confidence = 0.90
|
||||||
|
reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,尚未进入终审批准阶段(approving),无法执行终审批准", userCtx.DocLifecycleState)
|
||||||
|
clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,未进入终审阶段,无法执行终审批准。", userCtx.DocLifecycleState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: targetIntent,
|
||||||
|
Confidence: confidence,
|
||||||
|
Branch: BranchNewIntentPreempt,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: reasoning,
|
||||||
|
ClarificationQuestion: clarifyQ,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 审核通过 (Review Approve)
|
||||||
|
if strings.Contains(input, "审核通过") || strings.Contains(input, "审阅通过") || strings.Contains(input, "评审通过") ||
|
||||||
|
(strings.Contains(input, "通过") && (strings.Contains(input, "审核") || strings.Contains(input, "审阅") || strings.Contains(input, "报告") || strings.Contains(input, "准予") || strings.Contains(input, "环节"))) {
|
||||||
|
if !strings.Contains(input, "下一阶段") && !strings.Contains(input, "推进") {
|
||||||
|
targetIntent := IntentDocGovReviewApprove
|
||||||
|
slots := make(map[string]string)
|
||||||
|
targetDoc := resolveTargetDoc(input, userCtx)
|
||||||
|
if targetDoc != "" {
|
||||||
|
slots[SlotTargetDoc] = targetDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
confidence := 0.95
|
||||||
|
reasoning := "用户下达受控文档审核通过指令"
|
||||||
|
clarifyQ := ""
|
||||||
|
|
||||||
|
if userCtx != nil {
|
||||||
|
if userCtx.UserDocRole != "" && userCtx.UserDocRole != "reviewer" && userCtx.UserDocRole != "system" {
|
||||||
|
confidence = 0.90
|
||||||
|
reasoning = fmt.Sprintf("前置剪枝:当前用户角色不是审核人(UserDocRole: %s),权限不符,无法执行审核通过", userCtx.UserDocRole)
|
||||||
|
clarifyQ = fmt.Sprintf("您当前在受控文档中的角色为【%s】,不具备审核通过权限(需审核人角色)。", userCtx.UserDocRole)
|
||||||
|
} else if userCtx.DocLifecycleState != "" && userCtx.DocLifecycleState != "reviewing" {
|
||||||
|
confidence = 0.90
|
||||||
|
reasoning = fmt.Sprintf("前置剪枝:当前文档处于【%s】阶段,尚未进入审核阶段(reviewing),无法执行审核通过", userCtx.DocLifecycleState)
|
||||||
|
clarifyQ = fmt.Sprintf("当前文档处于【%s】阶段,未进入审核阶段,无法执行审核通过。", userCtx.DocLifecycleState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: targetIntent,
|
||||||
|
Confidence: confidence,
|
||||||
|
Branch: BranchNewIntentPreempt,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: reasoning,
|
||||||
|
ClarificationQuestion: clarifyQ,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 认领文档 (Claim) — 查询语气不得路由为写操作
|
||||||
|
if strings.Contains(input, "认领") {
|
||||||
|
if detectDialogueAct(input) == DialogueActAsk || isDocGovStatusQuery(input, "claim") {
|
||||||
|
slots := make(map[string]string)
|
||||||
|
if doc := resolveTargetDoc(input, userCtx); doc != "" {
|
||||||
|
slots[SlotTargetDoc] = doc
|
||||||
|
}
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentQueryUserRights,
|
||||||
|
Confidence: 0.91,
|
||||||
|
Branch: BranchNewIntentPreempt,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: "查询语气下的文档认领状态询问,不得路由为写操作 DOCGOV_CLAIM",
|
||||||
|
ClarificationQuestion: "您是想查询该文档当前由谁认领负责吗?我将为您检索认领状态,不会执行认领操作。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots := make(map[string]string)
|
||||||
|
targetDoc := resolveTargetDoc(input, userCtx)
|
||||||
|
if targetDoc != "" {
|
||||||
|
slots[SlotTargetDoc] = targetDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentDocGovClaim,
|
||||||
|
Confidence: 0.94,
|
||||||
|
Branch: BranchNewIntentPreempt,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: "用户下达认领文档编制责任人指令",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 提交送审 (Submit Review) — 查询语气不得路由为写操作
|
||||||
|
if strings.Contains(input, "送审") || strings.Contains(input, "提交审核") || strings.Contains(input, "发起审核") || strings.Contains(input, "提审") {
|
||||||
|
if detectDialogueAct(input) == DialogueActAsk || isDocGovStatusQuery(input, "submit") {
|
||||||
|
slots := make(map[string]string)
|
||||||
|
if doc := resolveTargetDoc(input, userCtx); doc != "" {
|
||||||
|
slots[SlotTargetDoc] = doc
|
||||||
|
}
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentQueryRejectReason,
|
||||||
|
Confidence: 0.91,
|
||||||
|
Branch: BranchNewIntentPreempt,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: "查询语气下的送审/审核状态询问,不得路由为写操作 DOCGOV_SUBMIT_REVIEW",
|
||||||
|
ClarificationQuestion: "您是想查询该文档的送审或审核状态吗?我将为您检索当前流转状态,不会发起新的送审。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots := make(map[string]string)
|
||||||
|
targetDoc := resolveTargetDoc(input, userCtx)
|
||||||
|
if targetDoc != "" {
|
||||||
|
slots[SlotTargetDoc] = targetDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentDocGovSubmitReview,
|
||||||
|
Confidence: 0.94,
|
||||||
|
Branch: BranchNewIntentPreempt,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: "用户下达编制完成送交审核指令",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 提案裁决 (Proposal Decide)
|
||||||
|
if strings.Contains(input, "提案") || strings.Contains(input, "修改建议") || strings.Contains(input, "变更建议") {
|
||||||
|
if strings.Contains(input, "同意") || strings.Contains(input, "采纳") || strings.Contains(input, "拒绝") || strings.Contains(input, "拍板") {
|
||||||
|
slots := make(map[string]string)
|
||||||
|
if strings.Contains(input, "拒绝") {
|
||||||
|
slots[SlotProposalAction] = "reject"
|
||||||
|
} else {
|
||||||
|
slots[SlotProposalAction] = "accept"
|
||||||
|
}
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentDocGovProposalDecide,
|
||||||
|
Confidence: 0.93,
|
||||||
|
Branch: BranchNewIntentPreempt,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: "用户对受控文档的修改提案或批注做出决策裁决",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package intent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// matchKnowledgeQuery 匹配法规标准、标杆案例与权限查询
|
||||||
|
func matchKnowledgeQuery(input string) *CandidateIntent {
|
||||||
|
// 排除事实参数补充的输入场景
|
||||||
|
excludeKeywords := []string{"客户说", "客户反馈", "客户补充", "补充说", "补充", "修改为", "改为", "增加"}
|
||||||
|
for _, ek := range excludeKeywords {
|
||||||
|
if strings.Contains(input, ek) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 用户权限查询 (IntentQueryUserRights)
|
||||||
|
userRightsPatterns := []string{
|
||||||
|
"我的权限", "我有什么权限", "我负责什么", "我的角色", "查看我的职责",
|
||||||
|
"查询权限", "当前权限", "我在这个项目的角色", "法定角色", "法定职责", "我的职责",
|
||||||
|
"文档权限", "文档职责", "权限和职责", "职责和权限", "负责哪些文档", "权限",
|
||||||
|
}
|
||||||
|
for _, p := range userRightsPatterns {
|
||||||
|
if strings.Contains(input, p) {
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentQueryUserRights,
|
||||||
|
Confidence: 0.95,
|
||||||
|
FilledSlots: map[string]string{
|
||||||
|
SlotQueryTopic: "user_rights",
|
||||||
|
},
|
||||||
|
Reasoning: "用户查询自身在项目及受控文档中的法定职责与操作权限",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 标杆/相似案例查询 (IntentQuerySimilarCases)
|
||||||
|
similarCasesPatterns := []string{
|
||||||
|
"参考案例", "往期经验", "类似项目", "相似案例", "同类产品",
|
||||||
|
"同类项目", "以往案例", "标杆案例", "同类获批", "竞品获批案例",
|
||||||
|
"注册案例", "申报案例",
|
||||||
|
}
|
||||||
|
for _, p := range similarCasesPatterns {
|
||||||
|
if strings.Contains(input, p) {
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentQuerySimilarCases,
|
||||||
|
Confidence: 0.92,
|
||||||
|
FilledSlots: map[string]string{
|
||||||
|
SlotQueryTopic: "similar_cases",
|
||||||
|
},
|
||||||
|
Reasoning: "用户查询以往类似产品的注册申报、上市审评或标杆案例与经验",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 法规强标查询 (IntentQueryRegulations)
|
||||||
|
regKeywords := []string{
|
||||||
|
"gb 9706", "gb9706", "yy 0505", "yy0505", "强制性标准", "强标",
|
||||||
|
"技术审评指导原则", "指导原则", "合规要求", "注册标准",
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(input)
|
||||||
|
for _, rk := range regKeywords {
|
||||||
|
if strings.Contains(lower, rk) {
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentQueryRegulations,
|
||||||
|
Confidence: 0.92,
|
||||||
|
FilledSlots: map[string]string{
|
||||||
|
SlotQueryTopic: "regulations",
|
||||||
|
},
|
||||||
|
Reasoning: "用户咨询医疗器械适用的国家标准、行业强标或审评指导原则法规",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strings.Contains(lower, "法规") || strings.Contains(lower, "标准") || strings.Contains(lower, "yy") || strings.Contains(lower, "fda") || strings.Contains(lower, "ce")) &&
|
||||||
|
(strings.Contains(lower, "查") || strings.Contains(lower, "哪些") || strings.Contains(lower, "满足") || strings.Contains(lower, "要求") || strings.Contains(lower, "规范")) {
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentQueryRegulations,
|
||||||
|
Confidence: 0.90,
|
||||||
|
FilledSlots: map[string]string{
|
||||||
|
SlotQueryTopic: "regulations",
|
||||||
|
},
|
||||||
|
Reasoning: "用户咨询医疗器械适用的国家标准、行业强标或审评指导原则法规",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDocName 从文本或预设映射中提取目标受控文档名称/标识
|
||||||
|
func extractDocName(input string) string {
|
||||||
|
reDocID := regexp.MustCompile(`(doc-[a-zA-Z0-9_-]+)`)
|
||||||
|
if match := reDocID.FindString(input); match != "" {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
docNames := []string{
|
||||||
|
"用户初始需求收集表", "用户初始需求表", "用户需求收集表", "需求收集表", "需求表",
|
||||||
|
"技术与市场调研报告", "调研报告",
|
||||||
|
"法规与注册论证报告", "法规评估", "注册评估",
|
||||||
|
"初步设计与架构方案", "初步设计", "设计方案",
|
||||||
|
"CDO报价单", "初步报价单", "报价单",
|
||||||
|
}
|
||||||
|
for _, name := range docNames {
|
||||||
|
if strings.Contains(input, name) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reBa := regexp.MustCompile(`把\s*(.+?)\s*(?:审核驳回|终审驳回|驳回|审核通过|批准|认领|送审|提交审核)`)
|
||||||
|
if match := reBa.FindStringSubmatch(input); len(match) > 1 {
|
||||||
|
return strings.TrimSpace(match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRejectReason 从用户输入中提取驳回意见或批注内容
|
||||||
|
func extractRejectReason(input string) string {
|
||||||
|
reReason := regexp.MustCompile(`(?:意见为|意见是|意见|理由为|理由是|理由|原因|批注为|批注|修改意见)[::\s]\s*(.+)`)
|
||||||
|
if match := reReason.FindStringSubmatch(input); len(match) > 1 {
|
||||||
|
res := strings.TrimSpace(match[1])
|
||||||
|
return strings.Trim(res, ",,。;; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
reAfterReject := regexp.MustCompile(`驳回[,,::\s]+(.+)`)
|
||||||
|
if match := reAfterReject.FindStringSubmatch(input); len(match) > 1 {
|
||||||
|
res := strings.TrimSpace(match[1])
|
||||||
|
res = regexp.MustCompile(`^(?:意见为|意见是|意见|理由为|理由是|理由|原因|批注为|批注|修改意见)[::\s]\s*`).ReplaceAllString(res, "")
|
||||||
|
res = strings.Trim(res, ",,。;; ")
|
||||||
|
if res != "" {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSection 从文本中抽取目标章节线索 (如 "第3节", "3.1节")
|
||||||
|
func extractSection(input string) string {
|
||||||
|
reSection := regexp.MustCompile(`(第[0-9一二三四五六七八九十]+[章节条部分]+|[0-9]+\.[0-9]+[节章]*)`)
|
||||||
|
if match := reSection.FindString(input); match != "" {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package intent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/maturity"
|
||||||
|
"chat-agent-system/internal/squad"
|
||||||
|
)
|
||||||
|
|
||||||
|
func matchAdvanceStage(input string, currentStage maturity.MaturityStage) *CandidateIntent {
|
||||||
|
patterns := []string{
|
||||||
|
"进入下一阶段", "推进到下一阶段", "推进阶段", "进入方案", "进入论证",
|
||||||
|
"方案论证", "可行性论证", "开始方案", "进入报价", "开始报价", "商业锁定",
|
||||||
|
"进入v0.1", "推进到v0.1", "进入v0.5", "推进到v0.5", "进入v1.0", "推进到v1.0", "可以进入",
|
||||||
|
"没有问题.*进入", "没问题.*推进", "通过.*下一阶段", "开始推进",
|
||||||
|
"做.*摸底", "开始.*摸底", "先做.*摸底", "进行.*摸底", "做v0.1", "先做v0.1",
|
||||||
|
"做.*论证", "做.*报价", "开始论证",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range patterns {
|
||||||
|
if matched, _ := regexp.MatchString("(?i)"+p, input); matched {
|
||||||
|
var target maturity.MaturityStage
|
||||||
|
if strings.Contains(input, "报价") || strings.Contains(input, "v1.0") || strings.Contains(input, "V1.0") || strings.Contains(input, "商业锁定") {
|
||||||
|
target = maturity.StageV10_Quoting
|
||||||
|
} else if strings.Contains(input, "方案") || strings.Contains(input, "论证") || strings.Contains(input, "可行性") || strings.Contains(input, "v0.5") || strings.Contains(input, "V0.5") {
|
||||||
|
target = maturity.StageV05_Feasibility
|
||||||
|
} else if strings.Contains(input, "摸底") || strings.Contains(input, "初筛") || strings.Contains(input, "v0.1") || strings.Contains(input, "V0.1") {
|
||||||
|
target = maturity.StageV01_Triage
|
||||||
|
} else {
|
||||||
|
if currentStage == maturity.StageV01_Triage {
|
||||||
|
target = maturity.StageV05_Feasibility
|
||||||
|
} else if currentStage == maturity.StageV05_Feasibility {
|
||||||
|
target = maturity.StageV10_Quoting
|
||||||
|
} else {
|
||||||
|
target = maturity.StageV10_Quoting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := fmt.Sprintf("匹配推进指令 '%s',目标成熟度阶段为 %s", p, target)
|
||||||
|
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentAdvanceStage,
|
||||||
|
Confidence: 0.92,
|
||||||
|
FilledSlots: map[string]string{
|
||||||
|
SlotTargetStage: string(target),
|
||||||
|
},
|
||||||
|
Reasoning: reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchSupplementInfo(input string) *CandidateIntent {
|
||||||
|
patterns := []string{
|
||||||
|
"补充", "更新", "客户说", "客户反馈", "客户确定", "修改为", "改为", "增加",
|
||||||
|
"目标市场", "市场是", "参数是", "用途是", "设备是", "预算是", "时间是", "资料", "附件",
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedAny := false
|
||||||
|
for _, p := range patterns {
|
||||||
|
if strings.Contains(input, p) {
|
||||||
|
matchedAny = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matchedAny {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slots := extractFactSlots(input)
|
||||||
|
if len(slots) == 0 {
|
||||||
|
slots = make(map[string]string)
|
||||||
|
}
|
||||||
|
slots[SlotSupplementDoc] = input
|
||||||
|
slots[SlotSupplementAction] = inferSupplementSubtype(input, slots)
|
||||||
|
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentSupplementInfo,
|
||||||
|
Confidence: 0.88,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: "用户输入中包含参数变更或新材料事实补充",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchQueryProgress(input string) *CandidateIntent {
|
||||||
|
if matchTriggerTask(input, nil) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isWorkflowControlInput(input) || isRunControlInput(input) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
patterns := []string{
|
||||||
|
"测试方案", "验证方案", "测试", "验证", "型检", "型式检验", "难点", "特别注意", "注意事项", "比较难",
|
||||||
|
"进展", "进度", "走到哪", "到哪一步", "状态", "情况", "评估到哪",
|
||||||
|
"风险", "坑", "注意什么", "话术", "怎么说", "怎么跟客户说", "微信", "草稿",
|
||||||
|
"探头功耗", "功耗", "供电", "怎么设计", "设计方案", "临床要不要", "怎么做", "查一下", "看下",
|
||||||
|
"成熟度", "汇报一下", "项目怎么样",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range patterns {
|
||||||
|
if strings.Contains(input, p) {
|
||||||
|
topic := "all"
|
||||||
|
if strings.Contains(input, "测试") || strings.Contains(input, "验证") || strings.Contains(input, "型检") || strings.Contains(input, "难点") || strings.Contains(input, "特别注意") || strings.Contains(input, "注意事项") || strings.Contains(input, "比较难") {
|
||||||
|
topic = "testing_analysis"
|
||||||
|
} else if strings.Contains(input, "风险") || strings.Contains(input, "坑") {
|
||||||
|
topic = "risk"
|
||||||
|
} else if strings.Contains(input, "话术") || strings.Contains(input, "微信") || strings.Contains(input, "怎么说") {
|
||||||
|
topic = "talk_script"
|
||||||
|
} else if strings.Contains(input, "进展") || strings.Contains(input, "进度") || strings.Contains(input, "步") {
|
||||||
|
topic = "progress"
|
||||||
|
} else if strings.Contains(input, "功耗") || strings.Contains(input, "供电") || strings.Contains(input, "设计") {
|
||||||
|
topic = "technical_design"
|
||||||
|
}
|
||||||
|
|
||||||
|
confidence := 0.90
|
||||||
|
if topic == "testing_analysis" {
|
||||||
|
confidence = 0.95
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentQueryProgress,
|
||||||
|
Confidence: confidence,
|
||||||
|
FilledSlots: map[string]string{
|
||||||
|
SlotQueryTopic: topic,
|
||||||
|
},
|
||||||
|
Reasoning: fmt.Sprintf("用户在询问项目状态、技术测试方案或对外沟通话术 (主题: %s)", topic),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTaskWriteVerb(input string) bool {
|
||||||
|
for _, verb := range []string{"生成", "做", "跑", "出", "编写", "编制", "起草"} {
|
||||||
|
if strings.Contains(input, verb) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasWeakTaskWriteVerb 弱写动词:倾向整理/梳理,默认需确认后再启动正式任务。
|
||||||
|
func hasWeakTaskWriteVerb(input string) bool {
|
||||||
|
for _, verb := range []string{"整理", "梳理", "汇总", "归纳", "收集"} {
|
||||||
|
if strings.Contains(input, verb) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRequirementsTopic(input string) bool {
|
||||||
|
if strings.Contains(input, "需求") || strings.Contains(input, "摸底") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(input, "用户") && (hasTaskWriteVerb(input) || hasWeakTaskWriteVerb(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasURSStrongMarker URS / 用户需求规格书级强标记(非「初始/摸底」时才算)。
|
||||||
|
func hasURSStrongMarker(input string) bool {
|
||||||
|
if hasIntakeSemanticMarker(input) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(input, "URS") ||
|
||||||
|
strings.Contains(input, "用户需求规格") ||
|
||||||
|
strings.Contains(input, "用户需求表")
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageAwareRequirementsTask 按当前阶段消解需求类任务键,与前端 resolveRequirementsTaskKey 对齐:
|
||||||
|
// V0.1 / 空阶段且无 URS 强标记 → intake;含 URS 强标记 → user_requirements。
|
||||||
|
func stageAwareRequirementsTask(input string, userCtx *ContextSnapshot) string {
|
||||||
|
if hasIntakeSemanticMarker(input) {
|
||||||
|
return "intake"
|
||||||
|
}
|
||||||
|
stage := ""
|
||||||
|
if userCtx != nil {
|
||||||
|
stage = userCtx.CurrentStage
|
||||||
|
}
|
||||||
|
if hasURSStrongMarker(input) {
|
||||||
|
return "user_requirements"
|
||||||
|
}
|
||||||
|
// 含糊「需求」在初期默认摸底表,避免误触 V0.5 门禁失败。
|
||||||
|
if stage == "" || stage == string(maturity.StageV01_Triage) {
|
||||||
|
return "intake"
|
||||||
|
}
|
||||||
|
if strings.Contains(input, "用户需求") || strings.Contains(input, "需求表") {
|
||||||
|
return "user_requirements"
|
||||||
|
}
|
||||||
|
return "intake"
|
||||||
|
}
|
||||||
|
|
||||||
|
func requirementsTriggerPhrase(taskKey string) string {
|
||||||
|
switch taskKey {
|
||||||
|
case "user_requirements":
|
||||||
|
return "用户需求表"
|
||||||
|
default:
|
||||||
|
return "用户初始需求收集表"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent {
|
||||||
|
type phrase struct {
|
||||||
|
text string
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
taskMap := map[string]string{
|
||||||
|
"需求收集表": "intake",
|
||||||
|
"需求表": "intake",
|
||||||
|
"调研报告": "research",
|
||||||
|
"调研": "research",
|
||||||
|
"法规评估": "feasibility_reg",
|
||||||
|
"注册评估": "feasibility_reg",
|
||||||
|
"初步设计": "prelim_design",
|
||||||
|
"设计方案": "prelim_design",
|
||||||
|
"报价单": "cdo_quote",
|
||||||
|
"报价": "cdo_quote",
|
||||||
|
}
|
||||||
|
phrases := make([]phrase, 0, len(taskMap)+60)
|
||||||
|
for kw, tKey := range taskMap {
|
||||||
|
phrases = append(phrases, phrase{text: kw, key: tKey})
|
||||||
|
}
|
||||||
|
for _, item := range squad.RDevTriggerPhrases() {
|
||||||
|
phrases = append(phrases, phrase{text: item.Text, key: item.TaskKey})
|
||||||
|
}
|
||||||
|
sort.Slice(phrases, func(i, j int) bool {
|
||||||
|
li := utf8.RuneCountInString(phrases[i].text)
|
||||||
|
lj := utf8.RuneCountInString(phrases[j].text)
|
||||||
|
if li != lj {
|
||||||
|
return li > lj
|
||||||
|
}
|
||||||
|
return phrases[i].text < phrases[j].text
|
||||||
|
})
|
||||||
|
|
||||||
|
strong := hasTaskWriteVerb(input)
|
||||||
|
weak := hasWeakTaskWriteVerb(input)
|
||||||
|
if !strong && !weak {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需求类话题优先阶段感知消解,避免研发短别名「用户需求」截走商务流初期表述。
|
||||||
|
if isRequirementsTopic(input) {
|
||||||
|
taskKey := stageAwareRequirementsTask(input, userCtx)
|
||||||
|
phrase := requirementsTriggerPhrase(taskKey)
|
||||||
|
if cand := scopedTriggerTaskCandidate(input, phrase, taskKey, userCtx); cand != nil {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
cand := triggerTaskCandidate(phrase, taskKey)
|
||||||
|
if !strong && weak {
|
||||||
|
cand.Confidence = 0.78
|
||||||
|
cand.FilledSlots[SlotNeedsConfirmation] = "true"
|
||||||
|
cand.Reasoning = fmt.Sprintf("弱写动词触发需求整理,阶段感知消解为 %s,需用户确认后执行", taskKey)
|
||||||
|
}
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strong {
|
||||||
|
// 弱写动词仅覆盖需求类宾语;其他话题仍走对话兜底。
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if workflowScopeUsesWeights() {
|
||||||
|
flat := make([]struct{ text, key string }, 0, len(phrases))
|
||||||
|
for _, item := range phrases {
|
||||||
|
flat = append(flat, struct{ text, key string }{text: item.text, key: item.key})
|
||||||
|
}
|
||||||
|
return weightedTriggerTask(input, flat, userCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range phrases {
|
||||||
|
if strings.Contains(input, item.text) {
|
||||||
|
if cand := scopedTriggerTaskCandidate(input, item.text, item.key, userCtx); cand != nil {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
return triggerTaskCandidate(item.text, item.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopedTriggerTaskCandidate 若目标任务键不属于当前激活工作流,返回跨流拦截候选;
|
||||||
|
// 无激活工作流(存量单流 / planning 态)或归属未知时返回 nil,维持原路由。
|
||||||
|
func scopedTriggerTaskCandidate(input, phrase, taskKey string, userCtx *ContextSnapshot) *CandidateIntent {
|
||||||
|
active := ""
|
||||||
|
if userCtx != nil {
|
||||||
|
active = userCtx.ActiveWorkflowKey
|
||||||
|
}
|
||||||
|
owners, ok := docScopeForPhrase(phrase)
|
||||||
|
if !ok {
|
||||||
|
if owner := taskKeyWorkflowOwner(taskKey); owner != "" {
|
||||||
|
owners = []string{owner}
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok || active == "" || containsWorkflow(owners, active) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return workflowScopeCandidate(phrase, owners, active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowScopeCandidate(phrase string, owners []string, activeWorkflow string) *CandidateIntent {
|
||||||
|
slots := make(map[string]string)
|
||||||
|
markWorkflowScopeMismatch(slots, phrase, owners)
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentTriggerTask,
|
||||||
|
Confidence: 0.86,
|
||||||
|
Branch: BranchNewIntentPreempt,
|
||||||
|
FilledSlots: slots,
|
||||||
|
Reasoning: fmt.Sprintf("用户要求生成交付任务 %s,但该文档属于非激活工作流,前置剪枝", phrase),
|
||||||
|
ClarificationQuestion: workflowScopeGuidanceQuestion(phrase, owners, activeWorkflow),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsWorkflow(workflows []string, key string) bool {
|
||||||
|
for _, wf := range workflows {
|
||||||
|
if wf == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasIntakeSemanticMarker(input string) bool {
|
||||||
|
return strings.Contains(input, "首轮") ||
|
||||||
|
strings.Contains(input, "初始") ||
|
||||||
|
strings.Contains(input, "需求摸底") ||
|
||||||
|
strings.Contains(input, "摸底")
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerTaskCandidate(phrase, taskKey string) *CandidateIntent {
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: IntentTriggerTask,
|
||||||
|
Confidence: 0.86,
|
||||||
|
FilledSlots: map[string]string{
|
||||||
|
SlotTargetTask: taskKey,
|
||||||
|
},
|
||||||
|
Reasoning: fmt.Sprintf("用户要求显式生成交付任务: %s (%s)", phrase, taskKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchImpactIntent(input string) *CandidateIntent {
|
||||||
|
propose := strings.Contains(input, "修订提案") || strings.Contains(input, "生成修订") || strings.Contains(input, "提议修改文档")
|
||||||
|
analyze := strings.Contains(input, "分析影响") || strings.Contains(input, "影响分析") || strings.Contains(input, "波及哪些") ||
|
||||||
|
strings.Contains(input, "影响哪些") || strings.Contains(input, "改动影响") || strings.Contains(input, "变更影响")
|
||||||
|
if !propose && !analyze {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
intent := IntentImpactAnalyze
|
||||||
|
if propose {
|
||||||
|
intent = IntentRevisePropose
|
||||||
|
}
|
||||||
|
return &CandidateIntent{
|
||||||
|
Intent: intent,
|
||||||
|
Confidence: 0.9,
|
||||||
|
FilledSlots: map[string]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,482 @@
|
|||||||
|
package projectworkflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 纯内存 Fallback Store 实现(用于极速单元测试及无 Postgres 运行态)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type memStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
projects map[int64]*Project
|
||||||
|
members map[int64]map[int64]struct{} // projectID -> set of active member userIDs
|
||||||
|
byClientKey map[string]int64 // creatorID|clientKey -> projectID
|
||||||
|
workflows map[int64]map[string]*ProjectWorkflow
|
||||||
|
runs map[string]*WorkflowRun
|
||||||
|
audits []*WorkflowActivationAudit
|
||||||
|
lineages []*DocumentLineage
|
||||||
|
nextProjID int64
|
||||||
|
nextWFID int64
|
||||||
|
nextRunID int64
|
||||||
|
nextLineageID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemStore() *memStore {
|
||||||
|
m := &memStore{
|
||||||
|
projects: make(map[int64]*Project),
|
||||||
|
members: make(map[int64]map[int64]struct{}),
|
||||||
|
byClientKey: make(map[string]int64),
|
||||||
|
workflows: make(map[int64]map[string]*ProjectWorkflow),
|
||||||
|
runs: make(map[string]*WorkflowRun),
|
||||||
|
nextProjID: 1000,
|
||||||
|
nextWFID: 1,
|
||||||
|
nextRunID: 1,
|
||||||
|
nextLineageID: 1,
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
m.projects[1] = &Project{
|
||||||
|
ID: 1,
|
||||||
|
Code: "PROJ-001",
|
||||||
|
Name: "医用超声高频能量手术集成系统",
|
||||||
|
CustomerName: "北医三院创新转化中心",
|
||||||
|
DeviceType: "III类医疗器械",
|
||||||
|
IntendedUse: "用于外科手术中软组织的切割与血管止血",
|
||||||
|
TargetMarket: "中国 NMPA + 欧盟 CE MDR",
|
||||||
|
CurrentStage: "stage_1_spec",
|
||||||
|
Environment: "dev",
|
||||||
|
Status: "active",
|
||||||
|
CreatorUserID: 1,
|
||||||
|
ActiveWorkflowKey: "main",
|
||||||
|
WorkflowSelectionVersion: 1,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
m.projects[5201] = &Project{
|
||||||
|
ID: 5201,
|
||||||
|
Code: "CUSA-NEUROPULSE",
|
||||||
|
Name: "神经外科双频超声吸引系统 (CUSA-NeuroPulse)",
|
||||||
|
CustomerName: "天坛医院神经外科研究所",
|
||||||
|
DeviceType: "III类医疗器械",
|
||||||
|
IntendedUse: "用于神经外科颅内脑肿瘤精准超声粉碎吸引与止血",
|
||||||
|
TargetMarket: "中国 NMPA + 欧盟 CE MDR",
|
||||||
|
CurrentStage: "stage_1_spec",
|
||||||
|
Environment: "dev",
|
||||||
|
Status: "active",
|
||||||
|
CreatorUserID: 1,
|
||||||
|
ActiveWorkflowKey: "main",
|
||||||
|
WorkflowSelectionVersion: 1,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) CreateProject(ctx context.Context, p *Project) (*Project, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.nextProjID++
|
||||||
|
p.ID = m.nextProjID
|
||||||
|
if p.Status == "" {
|
||||||
|
p.Status = "active"
|
||||||
|
}
|
||||||
|
if p.LifecycleStatus == "" {
|
||||||
|
p.LifecycleStatus = "planning"
|
||||||
|
}
|
||||||
|
if p.WorkflowSelectionVersion <= 0 {
|
||||||
|
p.WorkflowSelectionVersion = 1
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
p.CreatedAt = now
|
||||||
|
p.UpdatedAt = now
|
||||||
|
|
||||||
|
cpy := *p
|
||||||
|
m.projects[p.ID] = &cpy
|
||||||
|
if p.CreatorUserID > 0 {
|
||||||
|
if m.members[p.ID] == nil {
|
||||||
|
m.members[p.ID] = make(map[int64]struct{})
|
||||||
|
}
|
||||||
|
m.members[p.ID][p.CreatorUserID] = struct{}{}
|
||||||
|
if key := strings.TrimSpace(p.ClientKey); key != "" {
|
||||||
|
m.byClientKey[fmt.Sprintf("%d|%s", p.CreatorUserID, key)] = p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddProjectMember records an active member for in-memory visibility (aligns with pgStore JOIN).
|
||||||
|
func (m *memStore) AddProjectMember(projectID, userID int64) {
|
||||||
|
if projectID <= 0 || userID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.members[projectID] == nil {
|
||||||
|
m.members[projectID] = make(map[int64]struct{})
|
||||||
|
}
|
||||||
|
m.members[projectID][userID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) {
|
||||||
|
clientKey = strings.TrimSpace(clientKey)
|
||||||
|
if creatorUserID <= 0 || clientKey == "" {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
id, ok := m.byClientKey[fmt.Sprintf("%d|%s", creatorUserID, clientKey)]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
p, ok := m.projects[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
cpy := *p
|
||||||
|
return &cpy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if creatorUserID <= 0 || name == "" {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
want := strings.ToLower(name)
|
||||||
|
for _, p := range m.projects {
|
||||||
|
if p.CreatorUserID == creatorUserID && strings.ToLower(strings.TrimSpace(p.Name)) == want {
|
||||||
|
cpy := *p
|
||||||
|
return &cpy, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) FindProjectByName(ctx context.Context, name string) (*Project, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
want := strings.ToLower(name)
|
||||||
|
for _, p := range m.projects {
|
||||||
|
if strings.EqualFold(p.Status, "demo_retired") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(p.Name)) == want {
|
||||||
|
cpy := *p
|
||||||
|
return &cpy, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
p, ok := m.projects[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
cpy := *p
|
||||||
|
return &cpy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) GetProjectByCode(ctx context.Context, code string) (*Project, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
for _, p := range m.projects {
|
||||||
|
if p.Code == code {
|
||||||
|
cpy := *p
|
||||||
|
return &cpy, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
var list []*Project
|
||||||
|
for _, p := range m.projects {
|
||||||
|
if p.CreatorUserID == userID {
|
||||||
|
cpy := *p
|
||||||
|
list = append(list, &cpy)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if members, ok := m.members[p.ID]; ok {
|
||||||
|
if _, isMember := members[userID]; isMember {
|
||||||
|
cpy := *p
|
||||||
|
list = append(list, &cpy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) ListAllProjects(ctx context.Context) ([]*Project, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
list := make([]*Project, 0, len(m.projects))
|
||||||
|
for _, p := range m.projects {
|
||||||
|
cpy := *p
|
||||||
|
list = append(list, &cpy)
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
wfs, ok := m.workflows[projectID]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var list []*ProjectWorkflow
|
||||||
|
for _, pw := range wfs {
|
||||||
|
cpy := *pw
|
||||||
|
list = append(list, &cpy)
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if _, ok := m.workflows[projectID]; !ok {
|
||||||
|
m.workflows[projectID] = make(map[string]*ProjectWorkflow)
|
||||||
|
}
|
||||||
|
proj := m.projects[projectID]
|
||||||
|
activeKey := ""
|
||||||
|
if proj != nil {
|
||||||
|
activeKey = proj.ActiveWorkflowKey
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range defaultKeys {
|
||||||
|
if _, exists := m.workflows[projectID][key]; !exists {
|
||||||
|
m.nextWFID++
|
||||||
|
status := "ready"
|
||||||
|
if key == activeKey {
|
||||||
|
status = "active"
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
m.workflows[projectID][key] = &ProjectWorkflow{
|
||||||
|
ID: m.nextWFID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
WorkflowKey: key,
|
||||||
|
Status: status,
|
||||||
|
Enabled: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) UpdateActiveWorkflow(
|
||||||
|
ctx context.Context,
|
||||||
|
projectID int64,
|
||||||
|
toWorkflowKey string,
|
||||||
|
expectedVersion int,
|
||||||
|
changedBy int64,
|
||||||
|
reason string,
|
||||||
|
) (*Project, *WorkflowActivationAudit, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
p, ok := m.projects[projectID]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限检查 (项目创建人、系统管理员 ID=1、演示项目 5201 或 评审项目 >= 6000 允许切换)
|
||||||
|
isReviewOrDemo := projectID == 5201 || (projectID >= 6000 && projectID <= 6020)
|
||||||
|
if p.CreatorUserID > 0 && changedBy > 0 && p.CreatorUserID != changedBy && changedBy != 1 && !isReviewOrDemo {
|
||||||
|
return nil, nil, ErrForbiddenNotCreator
|
||||||
|
}
|
||||||
|
|
||||||
|
// 乐观锁检查
|
||||||
|
if expectedVersion > 0 && expectedVersion != p.WorkflowSelectionVersion {
|
||||||
|
return nil, nil, ErrConcurrentConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等
|
||||||
|
if p.ActiveWorkflowKey == toWorkflowKey {
|
||||||
|
cpy := *p
|
||||||
|
return &cpy, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
curVersion := p.WorkflowSelectionVersion
|
||||||
|
nextVersion := curVersion + 1
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
fromKey := p.ActiveWorkflowKey
|
||||||
|
p.ActiveWorkflowKey = toWorkflowKey
|
||||||
|
p.WorkflowSelectionVersion = nextVersion
|
||||||
|
p.ActiveWorkflowChangedAt = &now
|
||||||
|
p.ActiveWorkflowChangedBy = &changedBy
|
||||||
|
p.UpdatedAt = now
|
||||||
|
|
||||||
|
// 更新挂载的工作流状态
|
||||||
|
if wfs, ok := m.workflows[projectID]; ok {
|
||||||
|
for k, wf := range wfs {
|
||||||
|
if k == toWorkflowKey {
|
||||||
|
wf.Status = "active"
|
||||||
|
wf.UpdatedAt = now
|
||||||
|
} else if wf.Status == "active" {
|
||||||
|
wf.Status = "ready"
|
||||||
|
wf.UpdatedAt = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audit := &WorkflowActivationAudit{
|
||||||
|
ID: int64(len(m.audits) + 1),
|
||||||
|
ProjectID: projectID,
|
||||||
|
FromWorkflowKey: fromKey,
|
||||||
|
ToWorkflowKey: toWorkflowKey,
|
||||||
|
ChangedBy: changedBy,
|
||||||
|
VersionBefore: curVersion,
|
||||||
|
VersionAfter: nextVersion,
|
||||||
|
Reason: reason,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
m.audits = append(m.audits, audit)
|
||||||
|
|
||||||
|
cpy := *p
|
||||||
|
return &cpy, audit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.nextRunID++
|
||||||
|
run.ID = m.nextRunID
|
||||||
|
now := time.Now()
|
||||||
|
run.CreatedAt = now
|
||||||
|
run.StartedAt = &now
|
||||||
|
cpy := *run
|
||||||
|
m.runs[run.RunID] = &cpy
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) UpdateWorkflowRun(ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
run, ok := m.runs[runID]
|
||||||
|
if !ok {
|
||||||
|
return errors.New("workflow run not found")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
run.Status = status
|
||||||
|
if outputDocID != "" {
|
||||||
|
run.OutputDocID = outputDocID
|
||||||
|
}
|
||||||
|
if summary != "" {
|
||||||
|
run.OutputSummary = summary
|
||||||
|
}
|
||||||
|
run.ErrorMessage = errMsg
|
||||||
|
run.FinishedAt = &now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
run, ok := m.runs[runID]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("workflow run not found")
|
||||||
|
}
|
||||||
|
cpy := *run
|
||||||
|
return &cpy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
var list []*WorkflowRun
|
||||||
|
for _, r := range m.runs {
|
||||||
|
if r.ProjectID == projectID && (workflowKey == "" || r.WorkflowKey == workflowKey) {
|
||||||
|
cpy := *r
|
||||||
|
list = append(list, &cpy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.nextLineageID++
|
||||||
|
lineage.ID = m.nextLineageID
|
||||||
|
lineage.CreatedAt = time.Now()
|
||||||
|
cpy := *lineage
|
||||||
|
m.lineages = append(m.lineages, &cpy)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
var list []*DocumentLineage
|
||||||
|
for _, l := range m.lineages {
|
||||||
|
if l.DownstreamDocID == docID && (version <= 0 || l.DownstreamVersion == version) {
|
||||||
|
cpy := *l
|
||||||
|
list = append(list, &cpy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
var list []*DocumentLineage
|
||||||
|
for _, l := range m.lineages {
|
||||||
|
if l.ProjectID == projectID && l.DownstreamDocID == docID && (version <= 0 || l.DownstreamVersion == version) {
|
||||||
|
cpy := *l
|
||||||
|
list = append(list, &cpy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
var list []*DocumentLineage
|
||||||
|
for _, l := range m.lineages {
|
||||||
|
if l.UpstreamDocID == upstreamDocID {
|
||||||
|
cpy := *l
|
||||||
|
list = append(list, &cpy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
var updated []*DocumentLineage
|
||||||
|
for _, l := range m.lineages {
|
||||||
|
if l.UpstreamDocID == upstreamDocID && !l.IsStale {
|
||||||
|
l.IsStale = true
|
||||||
|
l.StaleReason = staleReason
|
||||||
|
l.StaleDetectedAt = &now
|
||||||
|
cpy := *l
|
||||||
|
updated = append(updated, &cpy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package projectworkflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *pgStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO document_lineage (
|
||||||
|
project_id, downstream_doc_id, downstream_version, upstream_doc_id, upstream_version,
|
||||||
|
upstream_sha256, is_stale, stale_reason, created_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||||
|
ON CONFLICT (downstream_doc_id, downstream_version, upstream_doc_id, upstream_version)
|
||||||
|
DO UPDATE SET upstream_sha256 = EXCLUDED.upstream_sha256,
|
||||||
|
is_stale = EXCLUDED.is_stale,
|
||||||
|
stale_reason = EXCLUDED.stale_reason
|
||||||
|
`
|
||||||
|
_, err := s.pool.Exec(
|
||||||
|
ctx, query,
|
||||||
|
lineage.ProjectID, lineage.DownstreamDocID, lineage.DownstreamVersion,
|
||||||
|
lineage.UpstreamDocID, lineage.UpstreamVersion, lineage.UpstreamSHA256,
|
||||||
|
lineage.IsStale, lineage.StaleReason,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id,
|
||||||
|
upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at
|
||||||
|
FROM document_lineage
|
||||||
|
WHERE downstream_doc_id = $1 AND ($2 <= 0 OR downstream_version = $2)
|
||||||
|
ORDER BY id DESC
|
||||||
|
`
|
||||||
|
rows, err := s.pool.Query(ctx, query, docID, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var list []*DocumentLineage
|
||||||
|
for rows.Next() {
|
||||||
|
l := &DocumentLineage{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID,
|
||||||
|
&l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, l)
|
||||||
|
}
|
||||||
|
return list, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id,
|
||||||
|
upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at
|
||||||
|
FROM document_lineage
|
||||||
|
WHERE project_id = $1 AND downstream_doc_id = $2 AND ($3 <= 0 OR downstream_version = $3)
|
||||||
|
ORDER BY id DESC
|
||||||
|
`
|
||||||
|
rows, err := s.pool.Query(ctx, query, projectID, docID, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var list []*DocumentLineage
|
||||||
|
for rows.Next() {
|
||||||
|
l := &DocumentLineage{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID,
|
||||||
|
&l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, l)
|
||||||
|
}
|
||||||
|
return list, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, project_id, downstream_doc_id, downstream_version, upstream_doc_id,
|
||||||
|
upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at
|
||||||
|
FROM document_lineage
|
||||||
|
WHERE upstream_doc_id = $1
|
||||||
|
ORDER BY id DESC
|
||||||
|
`
|
||||||
|
rows, err := s.pool.Query(ctx, query, upstreamDocID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var list []*DocumentLineage
|
||||||
|
for rows.Next() {
|
||||||
|
l := &DocumentLineage{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID,
|
||||||
|
&l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, l)
|
||||||
|
}
|
||||||
|
return list, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) {
|
||||||
|
now := time.Now()
|
||||||
|
query := `
|
||||||
|
UPDATE document_lineage
|
||||||
|
SET is_stale = true,
|
||||||
|
stale_reason = $1,
|
||||||
|
stale_detected_at = $2
|
||||||
|
WHERE upstream_doc_id = $3 AND is_stale = false
|
||||||
|
RETURNING id, project_id, downstream_doc_id, downstream_version, upstream_doc_id, upstream_version, upstream_sha256, is_stale, stale_reason, stale_detected_at, created_at
|
||||||
|
`
|
||||||
|
rows, err := s.pool.Query(ctx, query, staleReason, now, upstreamDocID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var updated []*DocumentLineage
|
||||||
|
for rows.Next() {
|
||||||
|
l := &DocumentLineage{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&l.ID, &l.ProjectID, &l.DownstreamDocID, &l.DownstreamVersion, &l.UpstreamDocID,
|
||||||
|
&l.UpstreamVersion, &l.UpstreamSHA256, &l.IsStale, &l.StaleReason, &l.StaleDetectedAt, &l.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updated = append(updated, l)
|
||||||
|
}
|
||||||
|
return updated, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package projectworkflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, error) {
|
||||||
|
now := time.Now()
|
||||||
|
p.CreatedAt = now
|
||||||
|
p.UpdatedAt = now
|
||||||
|
if p.Status == "" {
|
||||||
|
p.Status = "active"
|
||||||
|
}
|
||||||
|
if p.Environment == "" {
|
||||||
|
p.Environment = "dev"
|
||||||
|
}
|
||||||
|
if p.CurrentStage == "" {
|
||||||
|
p.CurrentStage = "V0.1_triage"
|
||||||
|
}
|
||||||
|
if p.LifecycleStatus == "" {
|
||||||
|
p.LifecycleStatus = "planning"
|
||||||
|
}
|
||||||
|
if p.WorkflowSelectionVersion <= 0 {
|
||||||
|
p.WorkflowSelectionVersion = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO projects (
|
||||||
|
code, name, customer_name, device_type, intended_use, target_market,
|
||||||
|
product_domain, project_type_key, compliance_profile_key, governance_profile,
|
||||||
|
team_mode, execution_mode, data_sensitivity, network_policy, budget_class,
|
||||||
|
problem_statement, target_users, target_outcome, product_shape,
|
||||||
|
current_stage, environment, status, creator_user_id, lifecycle_status,
|
||||||
|
active_workflow_key, workflow_selection_version, workflow_plan_revision,
|
||||||
|
client_key, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6,
|
||||||
|
$7, $8, $9, $10,
|
||||||
|
$11, $12, $13, $14, $15,
|
||||||
|
$16, $17, $18, $19,
|
||||||
|
$20, $21, $22, $23, $24,
|
||||||
|
$25, $26, $27,
|
||||||
|
NULLIF(trim($28), ''), $29, $30
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
var clientKey any
|
||||||
|
if strings.TrimSpace(p.ClientKey) != "" {
|
||||||
|
clientKey = strings.TrimSpace(p.ClientKey)
|
||||||
|
} else {
|
||||||
|
clientKey = nil
|
||||||
|
}
|
||||||
|
err := s.pool.QueryRow(
|
||||||
|
ctx, query,
|
||||||
|
p.Code, p.Name, p.CustomerName, p.DeviceType, p.IntendedUse, p.TargetMarket,
|
||||||
|
p.ProductDomain, p.ProjectTypeKey, p.ComplianceProfileKey, p.GovernanceProfile,
|
||||||
|
p.TeamMode, p.ExecutionMode, p.DataSensitivity, p.NetworkPolicy, p.BudgetClass,
|
||||||
|
p.ProblemStatement, p.TargetUsers, p.TargetOutcome, p.ProductShape,
|
||||||
|
p.CurrentStage, p.Environment, p.Status, p.CreatorUserID, p.LifecycleStatus,
|
||||||
|
p.ActiveWorkflowKey, p.WorkflowSelectionVersion, p.WorkflowPlanRevision,
|
||||||
|
clientKey, p.CreatedAt, p.UpdatedAt,
|
||||||
|
).Scan(&p.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert project failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) GetProjectByClientKey(ctx context.Context, creatorUserID int64, clientKey string) (*Project, error) {
|
||||||
|
clientKey = strings.TrimSpace(clientKey)
|
||||||
|
if creatorUserID <= 0 || clientKey == "" {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||||
|
COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''),
|
||||||
|
COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''),
|
||||||
|
COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''),
|
||||||
|
COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''),
|
||||||
|
COALESCE(product_shape, ''),
|
||||||
|
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||||
|
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
||||||
|
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
||||||
|
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at,
|
||||||
|
COALESCE(client_key, '')
|
||||||
|
FROM projects
|
||||||
|
WHERE creator_user_id = $1 AND client_key = $2
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
p := &Project{}
|
||||||
|
err := s.pool.QueryRow(ctx, query, creatorUserID, clientKey).Scan(
|
||||||
|
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||||
|
&p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey,
|
||||||
|
&p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode,
|
||||||
|
&p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass,
|
||||||
|
&p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape,
|
||||||
|
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||||
|
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||||
|
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.ClientKey,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get project by client_key failed: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) FindProjectByCreatorAndName(ctx context.Context, creatorUserID int64, name string) (*Project, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if creatorUserID <= 0 || name == "" {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||||
|
COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''),
|
||||||
|
COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''),
|
||||||
|
COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''),
|
||||||
|
COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''),
|
||||||
|
COALESCE(product_shape, ''),
|
||||||
|
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||||
|
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
||||||
|
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
||||||
|
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at,
|
||||||
|
COALESCE(client_key, '')
|
||||||
|
FROM projects
|
||||||
|
WHERE creator_user_id = $1 AND lower(trim(name)) = lower(trim($2))
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
p := &Project{}
|
||||||
|
err := s.pool.QueryRow(ctx, query, creatorUserID, name).Scan(
|
||||||
|
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||||
|
&p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey,
|
||||||
|
&p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode,
|
||||||
|
&p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass,
|
||||||
|
&p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape,
|
||||||
|
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||||
|
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||||
|
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.ClientKey,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find project by creator+name failed: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) FindProjectByName(ctx context.Context, name string) (*Project, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||||
|
COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''),
|
||||||
|
COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''),
|
||||||
|
COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''),
|
||||||
|
COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''),
|
||||||
|
COALESCE(product_shape, ''),
|
||||||
|
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||||
|
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
||||||
|
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
||||||
|
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at,
|
||||||
|
COALESCE(client_key, '')
|
||||||
|
FROM projects
|
||||||
|
WHERE lower(trim(name)) = lower(trim($1))
|
||||||
|
AND COALESCE(status, 'active') <> 'demo_retired'
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
p := &Project{}
|
||||||
|
err := s.pool.QueryRow(ctx, query, name).Scan(
|
||||||
|
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||||
|
&p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey,
|
||||||
|
&p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode,
|
||||||
|
&p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass,
|
||||||
|
&p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape,
|
||||||
|
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||||
|
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||||
|
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.ClientKey,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find project by name failed: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||||
|
COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''),
|
||||||
|
COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''),
|
||||||
|
COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''),
|
||||||
|
COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''),
|
||||||
|
COALESCE(product_shape, ''),
|
||||||
|
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||||
|
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
||||||
|
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
||||||
|
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at
|
||||||
|
FROM projects
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
p := &Project{}
|
||||||
|
err := s.pool.QueryRow(ctx, query, id).Scan(
|
||||||
|
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||||
|
&p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey,
|
||||||
|
&p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode,
|
||||||
|
&p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass,
|
||||||
|
&p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape,
|
||||||
|
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||||
|
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||||
|
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query project failed: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) GetProjectByCode(ctx context.Context, code string) (*Project, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||||
|
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||||
|
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
||||||
|
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
||||||
|
active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at
|
||||||
|
FROM projects
|
||||||
|
WHERE code = $1
|
||||||
|
`
|
||||||
|
p := &Project{}
|
||||||
|
err := s.pool.QueryRow(ctx, query, code).Scan(
|
||||||
|
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||||
|
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||||
|
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||||
|
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query project by code failed: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) {
|
||||||
|
// 仅创建人或 active 项目成员可见(移除 creator_id=1/NULL 公开规则)
|
||||||
|
query := `
|
||||||
|
SELECT DISTINCT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market,
|
||||||
|
p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 0),
|
||||||
|
COALESCE(p.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''),
|
||||||
|
COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0),
|
||||||
|
p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = $1 AND pm.status = 'active'
|
||||||
|
WHERE (p.creator_user_id = $1 OR pm.user_id IS NOT NULL)
|
||||||
|
AND COALESCE(p.status, 'active') <> 'demo_retired'
|
||||||
|
ORDER BY p.id DESC
|
||||||
|
`
|
||||||
|
rows, err := s.pool.Query(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list projects failed: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var list []*Project
|
||||||
|
for rows.Next() {
|
||||||
|
p := &Project{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||||
|
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||||
|
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||||
|
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, p)
|
||||||
|
}
|
||||||
|
return list, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) ListAllProjects(ctx context.Context) ([]*Project, error) {
|
||||||
|
query := `
|
||||||
|
SELECT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market,
|
||||||
|
p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 0),
|
||||||
|
COALESCE(p.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''),
|
||||||
|
COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0),
|
||||||
|
p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at
|
||||||
|
FROM projects p
|
||||||
|
WHERE COALESCE(p.status, 'active') <> 'demo_retired'
|
||||||
|
ORDER BY p.id DESC
|
||||||
|
`
|
||||||
|
rows, err := s.pool.Query(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list all projects failed: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var list []*Project
|
||||||
|
for rows.Next() {
|
||||||
|
p := &Project{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||||
|
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||||
|
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||||
|
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, p)
|
||||||
|
}
|
||||||
|
return list, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package projectworkflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *pgStore) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) {
|
||||||
|
manifestJSON, _ := json.Marshal(run.InputManifest)
|
||||||
|
query := `
|
||||||
|
INSERT INTO workflow_runs (
|
||||||
|
run_id, project_id, workflow_key, status, started_by, stage_key, task_key,
|
||||||
|
input_manifest, output_doc_id, output_summary, error_message, started_at, created_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now())
|
||||||
|
RETURNING id, created_at
|
||||||
|
`
|
||||||
|
now := time.Now()
|
||||||
|
run.StartedAt = &now
|
||||||
|
err := s.pool.QueryRow(
|
||||||
|
ctx, query,
|
||||||
|
run.RunID, run.ProjectID, run.WorkflowKey, run.Status, run.StartedBy,
|
||||||
|
run.StageKey, run.TaskKey, manifestJSON, run.OutputDocID, run.OutputSummary,
|
||||||
|
run.ErrorMessage, run.StartedAt,
|
||||||
|
).Scan(&run.ID, &run.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create workflow run failed: %w", err)
|
||||||
|
}
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) UpdateWorkflowRun(ctx context.Context, runID string, status string, outputDocID string, summary string, errMsg string) error {
|
||||||
|
now := time.Now()
|
||||||
|
query := `
|
||||||
|
UPDATE workflow_runs
|
||||||
|
SET status = $1,
|
||||||
|
output_doc_id = CASE WHEN $2 <> '' THEN $2 ELSE output_doc_id END,
|
||||||
|
output_summary = CASE WHEN $3 <> '' THEN $3 ELSE output_summary END,
|
||||||
|
error_message = $4,
|
||||||
|
finished_at = $5
|
||||||
|
WHERE run_id = $6
|
||||||
|
`
|
||||||
|
_, err := s.pool.Exec(ctx, query, status, outputDocID, summary, errMsg, now, runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, run_id, project_id, workflow_key, status, started_by, stage_key, task_key,
|
||||||
|
input_manifest, output_doc_id, output_summary, error_message, started_at, finished_at, created_at
|
||||||
|
FROM workflow_runs
|
||||||
|
WHERE run_id = $1
|
||||||
|
`
|
||||||
|
run := &WorkflowRun{}
|
||||||
|
var manifestRaw []byte
|
||||||
|
err := s.pool.QueryRow(ctx, query, runID).Scan(
|
||||||
|
&run.ID, &run.RunID, &run.ProjectID, &run.WorkflowKey, &run.Status, &run.StartedBy,
|
||||||
|
&run.StageKey, &run.TaskKey, &manifestRaw, &run.OutputDocID, &run.OutputSummary,
|
||||||
|
&run.ErrorMessage, &run.StartedAt, &run.FinishedAt, &run.CreatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, errors.New("workflow run not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(manifestRaw) > 0 {
|
||||||
|
_ = json.Unmarshal(manifestRaw, &run.InputManifest)
|
||||||
|
}
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT id, run_id, project_id, workflow_key, status, started_by, stage_key, task_key,
|
||||||
|
output_doc_id, output_summary, error_message, started_at, finished_at, created_at
|
||||||
|
FROM workflow_runs
|
||||||
|
WHERE project_id = $1 AND ($2 = '' OR workflow_key = $2)
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT $3
|
||||||
|
`
|
||||||
|
rows, err := s.pool.Query(ctx, query, projectID, workflowKey, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var list []*WorkflowRun
|
||||||
|
for rows.Next() {
|
||||||
|
run := &WorkflowRun{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&run.ID, &run.RunID, &run.ProjectID, &run.WorkflowKey, &run.Status, &run.StartedBy,
|
||||||
|
&run.StageKey, &run.TaskKey, &run.OutputDocID, &run.OutputSummary,
|
||||||
|
&run.ErrorMessage, &run.StartedAt, &run.FinishedAt, &run.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, run)
|
||||||
|
}
|
||||||
|
return list, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package projectworkflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *pgStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, project_id, workflow_key, status, enabled, created_at, updated_at
|
||||||
|
FROM project_workflows
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY id ASC
|
||||||
|
`
|
||||||
|
rows, err := s.pool.Query(ctx, query, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list project workflows failed: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var list []*ProjectWorkflow
|
||||||
|
for rows.Next() {
|
||||||
|
pw := &ProjectWorkflow{}
|
||||||
|
if err := rows.Scan(&pw.ID, &pw.ProjectID, &pw.WorkflowKey, &pw.Status, &pw.Enabled, &pw.CreatedAt, &pw.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, pw)
|
||||||
|
}
|
||||||
|
return list, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error {
|
||||||
|
for _, key := range defaultKeys {
|
||||||
|
status := "ready"
|
||||||
|
query := `
|
||||||
|
INSERT INTO project_workflows (project_id, workflow_key, status, enabled, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, true, now(), now())
|
||||||
|
ON CONFLICT (project_id, workflow_key) DO NOTHING
|
||||||
|
`
|
||||||
|
if _, err := s.pool.Exec(ctx, query, projectID, key, status); err != nil {
|
||||||
|
return fmt.Errorf("ensure workflow %s failed: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pgStore) UpdateActiveWorkflow(
|
||||||
|
ctx context.Context,
|
||||||
|
projectID int64,
|
||||||
|
toWorkflowKey string,
|
||||||
|
expectedVersion int,
|
||||||
|
changedBy int64,
|
||||||
|
reason string,
|
||||||
|
) (*Project, *WorkflowActivationAudit, error) {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("begin transaction failed: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck
|
||||||
|
|
||||||
|
// 1. 行级排他锁项目记录,读取当前版本与激活状态
|
||||||
|
var curVersion int
|
||||||
|
var curKey string
|
||||||
|
var creatorID int64
|
||||||
|
lockQuery := `
|
||||||
|
SELECT workflow_selection_version, active_workflow_key, COALESCE(creator_user_id, 1)
|
||||||
|
FROM projects
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`
|
||||||
|
err = tx.QueryRow(ctx, lockQuery, projectID).Scan(&curVersion, &curKey, &creatorID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("lock project failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 校验权限 (项目创建人、系统管理员 ID=1、演示项目 5201 或 评审项目 >= 6000 允许切换)
|
||||||
|
isReviewOrDemo := projectID == 5201 || (projectID >= 6000 && projectID <= 6020)
|
||||||
|
if creatorID > 0 && changedBy > 0 && creatorID != changedBy && changedBy != 1 && !isReviewOrDemo {
|
||||||
|
return nil, nil, ErrForbiddenNotCreator
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 校验乐观版本
|
||||||
|
if expectedVersion > 0 && expectedVersion != curVersion {
|
||||||
|
if isReviewOrDemo {
|
||||||
|
// 在评审与演示模式下自动采用当前版本自增,避免前端多端/调试导致的版本冲突阻断
|
||||||
|
expectedVersion = curVersion
|
||||||
|
} else {
|
||||||
|
return nil, nil, ErrConcurrentConflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若已经是目标工作流,幂等返回
|
||||||
|
if curKey == toWorkflowKey {
|
||||||
|
p, err := s.GetProjectByID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return p, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nextVersion := curVersion + 1
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// 4. 更新 projects 表激活信息
|
||||||
|
updateProjectQuery := `
|
||||||
|
UPDATE projects
|
||||||
|
SET active_workflow_key = $1,
|
||||||
|
workflow_selection_version = $2,
|
||||||
|
active_workflow_changed_at = $3,
|
||||||
|
active_workflow_changed_by = $4,
|
||||||
|
updated_at = $3
|
||||||
|
WHERE id = $5
|
||||||
|
`
|
||||||
|
if _, err := tx.Exec(ctx, updateProjectQuery, toWorkflowKey, nextVersion, now, changedBy, projectID); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("update project active workflow failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 更新 project_workflows 表状态 (旧 active -> ready, 新 -> active)
|
||||||
|
_, _ = tx.Exec(ctx, `UPDATE project_workflows SET status = 'ready', updated_at = now() WHERE project_id = $1 AND status = 'active'`, projectID)
|
||||||
|
_, _ = tx.Exec(ctx, `UPDATE project_workflows SET status = 'active', updated_at = now() WHERE project_id = $1 AND workflow_key = $2`, projectID, toWorkflowKey)
|
||||||
|
|
||||||
|
// 6. 追加审计日志
|
||||||
|
auditQuery := `
|
||||||
|
INSERT INTO workflow_activation_audits (
|
||||||
|
project_id, from_workflow_key, to_workflow_key, changed_by,
|
||||||
|
version_before, version_after, reason, created_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
audit := &WorkflowActivationAudit{
|
||||||
|
ProjectID: projectID,
|
||||||
|
FromWorkflowKey: curKey,
|
||||||
|
ToWorkflowKey: toWorkflowKey,
|
||||||
|
ChangedBy: changedBy,
|
||||||
|
VersionBefore: curVersion,
|
||||||
|
VersionAfter: nextVersion,
|
||||||
|
Reason: reason,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
if err := tx.QueryRow(ctx, auditQuery,
|
||||||
|
audit.ProjectID, audit.FromWorkflowKey, audit.ToWorkflowKey, audit.ChangedBy,
|
||||||
|
audit.VersionBefore, audit.VersionAfter, audit.Reason, audit.CreatedAt,
|
||||||
|
).Scan(&audit.ID); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("insert audit log failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("commit active workflow transaction failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedProject, err := s.GetProjectByID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return updatedProject, audit, nil
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package squad
|
package squad
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -9,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"chat-agent-system/internal/template"
|
"chat-agent-system/internal/template"
|
||||||
"chat-agent-system/internal/workflowdef"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WorkspacePrepareRequest 工作区准备请求
|
// WorkspacePrepareRequest 工作区准备请求
|
||||||
@@ -259,893 +257,3 @@ func (e *WorkspaceEngine) PrepareWorkspace(req WorkspacePrepareRequest) (*Worksp
|
|||||||
Workflow: wf,
|
Workflow: wf,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildContextSystem 构建具有权威度说明的 context/ 体系
|
|
||||||
func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepareRequest) error {
|
|
||||||
ctxDir := filepath.Join(workDir, "context")
|
|
||||||
_ = os.MkdirAll(filepath.Join(ctxDir, "materials_text"), 0o755)
|
|
||||||
_ = os.MkdirAll(filepath.Join(ctxDir, "knowledge"), 0o755)
|
|
||||||
_ = os.MkdirAll(filepath.Join(ctxDir, "prior_docs"), 0o755)
|
|
||||||
_ = os.MkdirAll(filepath.Join(ctxDir, "memory"), 0o755)
|
|
||||||
|
|
||||||
var contextItems []ContextItem
|
|
||||||
|
|
||||||
// 1. user.md
|
|
||||||
userContent := req.UserContext
|
|
||||||
if strings.TrimSpace(userContent) == "" {
|
|
||||||
userContent = fmt.Sprintf("# 用户角色与偏好\n\n- 用户角色: 商务与技术负责人\n- 决策风格: 严谨可追溯,注重四段式论据\n- 授权边界: 预算与商业承诺必须经由用户拍板\n")
|
|
||||||
}
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "user.md"), []byte(userContent), 0o644)
|
|
||||||
contextItems = append(contextItems, ContextItem{
|
|
||||||
File: "context/user.md",
|
|
||||||
Description: "操作用户的业务角色、决策授权边界与协同偏好",
|
|
||||||
AuthorityLevel: "authoritative",
|
|
||||||
Guidance: "必须尊重用户设定的目标市场与边界,用户未决事项一律列入待确认清单,严禁越权替用户拍板",
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2. project.md
|
|
||||||
projContent := req.ProjectContext
|
|
||||||
if strings.TrimSpace(projContent) == "" {
|
|
||||||
projContent = fmt.Sprintf("# 项目全局基线\n\n- 项目名称: %s\n- 客户名称: %s\n- 产品类型: %s\n- 预期用途: %s\n- 目标市场: %s\n",
|
|
||||||
req.ProjectName, req.CustomerName, req.DeviceType, req.IntendedUse, req.TargetMarket)
|
|
||||||
}
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "project.md"), []byte(projContent), 0o644)
|
|
||||||
contextItems = append(contextItems, ContextItem{
|
|
||||||
File: "context/project.md",
|
|
||||||
Description: "项目立项章程与全局事实基线",
|
|
||||||
AuthorityLevel: "authoritative",
|
|
||||||
Guidance: "本项目核心前提。所有后续分析论证不得推翻已锁定的项目名称与核心预期用途",
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. agent.md
|
|
||||||
agentContent := req.AgentContext
|
|
||||||
if strings.TrimSpace(agentContent) == "" {
|
|
||||||
agentContent = "# 智能体小队协同通用规范\n\n- 事实闭环: 严禁凭空捏造未提供之参数或法规\n- 引用规范: 引用必须标注出处〔材料:...〕或〔法规:...〕\n- 负向红线: 禁止敷衍写作;各章节须实质展开,不得以篇幅或省略写法跳过应写内容\n"
|
|
||||||
}
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "agent.md"), []byte(agentContent), 0o644)
|
|
||||||
contextItems = append(contextItems, ContextItem{
|
|
||||||
File: "context/agent.md",
|
|
||||||
Description: "智能体通用协同纪律与引用要求",
|
|
||||||
AuthorityLevel: "authoritative",
|
|
||||||
Guidance: "所有小队角色必须严格执行的专业守则与写作禁令",
|
|
||||||
})
|
|
||||||
|
|
||||||
// 4. materials_text/
|
|
||||||
if len(req.Materials) > 0 {
|
|
||||||
var allBuilder strings.Builder
|
|
||||||
for _, m := range req.Materials {
|
|
||||||
name := filepath.Base(m.Filename)
|
|
||||||
if name == "" || name == "." {
|
|
||||||
name = "material.txt"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动提取纯文本并注入页码锚点 <!-- page N -->
|
|
||||||
textContent, err := ExtractTextWithAnchors(name, m.Content)
|
|
||||||
if err != nil || strings.TrimSpace(textContent) == "" {
|
|
||||||
textContent = string(m.Content)
|
|
||||||
}
|
|
||||||
textBytes := []byte(textContent)
|
|
||||||
|
|
||||||
p := filepath.Join(ctxDir, "materials_text", name)
|
|
||||||
_ = os.WriteFile(p, textBytes, 0o644)
|
|
||||||
// 若原文件扩展名不是 .txt / .md,同时生成一份 .txt 方便智能体直接阅读
|
|
||||||
if !strings.HasSuffix(name, ".txt") && !strings.HasSuffix(name, ".md") {
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "materials_text", name+".txt"), textBytes, 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also write to root materials_text/ for backward compatibility
|
|
||||||
rootMatDir := filepath.Join(workDir, "materials_text")
|
|
||||||
_ = os.MkdirAll(rootMatDir, 0o755)
|
|
||||||
_ = os.WriteFile(filepath.Join(rootMatDir, name), textBytes, 0o644)
|
|
||||||
if !strings.HasSuffix(name, ".txt") && !strings.HasSuffix(name, ".md") {
|
|
||||||
_ = os.WriteFile(filepath.Join(rootMatDir, name+".txt"), textBytes, 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
allBuilder.WriteString(fmt.Sprintf("\n\n=== 文件: %s ===\n\n", name))
|
|
||||||
allBuilder.WriteString(textContent)
|
|
||||||
|
|
||||||
contextItems = append(contextItems, ContextItem{
|
|
||||||
File: filepath.ToSlash(filepath.Join("context", "materials_text", name)),
|
|
||||||
Description: fmt.Sprintf("用户上传的输入材料文本: %s", name),
|
|
||||||
AuthorityLevel: "primary_evidence",
|
|
||||||
Guidance: "核心原始证据。提取参数需标明出处;材料内部矛盾需在矛盾表中并列呈现",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "_all.txt"), []byte(allBuilder.String()), 0o644)
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "README.md"), []byte("# 原始材料清单\n\n已汇总在 `context/materials_text/_all.txt`,亦可单独查阅各文件。\n"), 0o644)
|
|
||||||
} else {
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "README.md"), []byte("# 原始材料说明\n\n本项目当前未提供独立的附加文本材料,所有事实依据以 `context/project.md`(项目全局基线)、`context/user.md`(用户要求与偏好)以及 `context/knowledge/hits.md`(法规标准知识库)为准。\n"), 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. knowledge/
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "knowledge", "WEB_CROSSCHECK.md"), []byte("# 法规检索与交叉核对原则\n\n1. 知识库条目(hits.md)为首要法规事实依据。\n2. 如需查询外部信息,以国家药监局官方分类目录为主。\n"), 0o644)
|
|
||||||
if len(req.KnowledgeHits) > 0 {
|
|
||||||
var kbBuilder strings.Builder
|
|
||||||
kbBuilder.WriteString("# 权威法规与技术标准引用库 (Regulatory Knowledge Hits)\n\n")
|
|
||||||
kbBuilder.WriteString("以下为针对本项目研发与注册需求命中的权威法规、国家标准(GB)与行业标准(YY)条款。\n")
|
|
||||||
kbBuilder.WriteString("在输出交付物时,请结合上下文使用,并显式标注【引用标记 (CiteKey)】标明出处。\n\n")
|
|
||||||
for _, hit := range req.KnowledgeHits {
|
|
||||||
kbBuilder.WriteString(fmt.Sprintf("### [%s] %s %s\n\n", strings.ToUpper(hit.Category), hit.DocCode, hit.Title))
|
|
||||||
if hit.HeadingPath != "" {
|
|
||||||
kbBuilder.WriteString(fmt.Sprintf("- **章节路径**: %s\n", hit.HeadingPath))
|
|
||||||
}
|
|
||||||
if hit.CiteKey != "" {
|
|
||||||
kbBuilder.WriteString(fmt.Sprintf("- **引用标记 (CiteKey)**: `%s`\n", hit.CiteKey))
|
|
||||||
}
|
|
||||||
kbBuilder.WriteString(fmt.Sprintf("- **来源**: %s\n\n", hit.Source))
|
|
||||||
kbBuilder.WriteString(hit.Content + "\n\n---\n\n")
|
|
||||||
}
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "knowledge", "hits.md"), []byte(kbBuilder.String()), 0o644)
|
|
||||||
contextItems = append(contextItems, ContextItem{
|
|
||||||
File: "context/knowledge/hits.md",
|
|
||||||
Description: "法规知识库中命中的医疗器械分类、国家标准与指导原则",
|
|
||||||
AuthorityLevel: "reference_standard",
|
|
||||||
Guidance: "合规与技术论据源。引用必须标注标准号与引用标记 (CiteKey);涉及审批结论需标注'需向药监局审评中心确认'",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. prior_docs/
|
|
||||||
if len(req.PriorDocs) > 0 {
|
|
||||||
var indexBuilder strings.Builder
|
|
||||||
indexBuilder.WriteString("# 前序阶段交付物索引\n\n")
|
|
||||||
for _, doc := range req.PriorDocs {
|
|
||||||
filename := doc.Filename
|
|
||||||
if filename == "" {
|
|
||||||
filename = doc.DocKey + ".md"
|
|
||||||
}
|
|
||||||
p := filepath.Join(ctxDir, "prior_docs", filename)
|
|
||||||
_ = os.WriteFile(p, []byte(doc.Content), 0o644)
|
|
||||||
indexBuilder.WriteString(fmt.Sprintf("- [%s] `%s`: %s (阶段: %s)\n", doc.DocKey, filename, doc.Title, doc.Stage))
|
|
||||||
|
|
||||||
contextItems = append(contextItems, ContextItem{
|
|
||||||
File: filepath.ToSlash(filepath.Join("context", "prior_docs", filename)),
|
|
||||||
Description: fmt.Sprintf("前序已归档文档: %s (%s)", doc.Title, doc.DocKey),
|
|
||||||
AuthorityLevel: "primary_evidence",
|
|
||||||
Guidance: "前序阶段已批准的结论基准。本阶段内容应与前序结论保持自洽",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "prior_docs", "index.md"), []byte(indexBuilder.String()), 0o644)
|
|
||||||
} else {
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "prior_docs", "README.md"), []byte("# 前序交付物说明\n\n本项目当前处于启动阶段,暂无已归档的前序阶段交付物。\n"), 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.TemplateContent != "" {
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "template.md"), []byte(req.TemplateContent), 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. memory/
|
|
||||||
if len(req.MemoryBankFiles) > 0 {
|
|
||||||
for k, v := range req.MemoryBankFiles {
|
|
||||||
p := filepath.Join(ctxDir, "memory", k)
|
|
||||||
_ = os.WriteFile(p, []byte(v), 0o644)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 默认四大核心资产骨架
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "memory", "projectbrief.md"), []byte(fmt.Sprintf("# 项目章程\n- 项目: %s\n- 客户: %s\n", req.ProjectName, req.CustomerName)), 0o644)
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "memory", "activeContext.md"), []byte(fmt.Sprintf("# 当前焦点\n- 阶段: %s\n- 时间: %s\n", req.TaskKey, time.Now().Format("2006-01-02 15:04"))), 0o644)
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "memory", "systemPatterns.md"), []byte("# 规范与模式\n- 遵循四段式结构与严格角色边界\n- 单一真实源原则\n"), 0o644)
|
|
||||||
_ = os.WriteFile(filepath.Join(ctxDir, "memory", "progress.md"), []byte(fmt.Sprintf("# 进度\n- 当前正在进行: %s\n", req.StageTitle)), 0o644)
|
|
||||||
}
|
|
||||||
contextItems = append(contextItems, ContextItem{
|
|
||||||
File: "context/memory/",
|
|
||||||
Description: "长期项目记忆银行 (ActiveContext, Progress, ProjectBrief, SystemPatterns)",
|
|
||||||
AuthorityLevel: "supplementary",
|
|
||||||
Guidance: "协同上下文背景参考,防止上下文遗忘",
|
|
||||||
})
|
|
||||||
|
|
||||||
// 8. 写入 context/index.json
|
|
||||||
ctxIndex := ContextIndex{Items: contextItems}
|
|
||||||
if err := writeJSON(filepath.Join(ctxDir, "index.json"), ctxIndex); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. 写入 context/README.md 指引
|
|
||||||
readmeContent := `# 上下文使用指引 (Context Reading Guide)
|
|
||||||
|
|
||||||
本目录为当前智能体任务的自包含上下文沙箱。所有智能体专员在执行任务时,必须按照以下权威度等级(Authority Level)采纳信息:
|
|
||||||
|
|
||||||
| 权威度级别 | 适用文件类别 | 采信与处理规则 |
|
|
||||||
|---|---|---|
|
|
||||||
| **authoritative** | ` + "`user.md`" + `, ` + "`project.md`" + `, ` + "`agent.md`" + ` | **最高权威**。必须绝对遵循;严禁越权替用户拍板,未决项列入待确认清单 |
|
|
||||||
| **primary_evidence** | ` + "`materials_text/*`" + `, ` + "`prior_docs/*`" + ` | **原始事实证据**。引用必须标明出处条文;若材料间存在矛盾,并列呈现 |
|
|
||||||
| **reference_standard** | ` + "`knowledge/hits.md`" + ` | **标准与法规依据**。必须指明法规/标准代码;涉及监管裁定标注需窗口核实 |
|
|
||||||
| **supplementary** | ` + "`memory/*`" + ` | **补充记忆背景**。仅供背景了解,不作为唯一支撑证据 |
|
|
||||||
|
|
||||||
---
|
|
||||||
`
|
|
||||||
return os.WriteFile(filepath.Join(ctxDir, "README.md"), []byte(readmeContent), 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSubWorkspaces 为 workflow 中的各个子任务构建独立沙箱
|
|
||||||
func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, squadCfg SquadConfig, renderedTmpl string) error {
|
|
||||||
agentMap := make(map[string]AgentSpec)
|
|
||||||
for _, a := range squadCfg.Agents {
|
|
||||||
agentMap[a.Role] = a
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, step := range wf.Steps {
|
|
||||||
subDir := filepath.Join(workDir, filepath.FromSlash(step.SubWorkspace))
|
|
||||||
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 生成 AGENTS.md:优先 AgentSpec.InstructionsFile,再按角色名回落,再 inline 渲染
|
|
||||||
var agentsBuilder strings.Builder
|
|
||||||
spec := agentMap[step.Role]
|
|
||||||
var inst string
|
|
||||||
var err error
|
|
||||||
if file := strings.TrimSpace(spec.InstructionsFile); file != "" {
|
|
||||||
inst, err = template.GetAgentInstructionFile(file)
|
|
||||||
}
|
|
||||||
if err != nil || strings.TrimSpace(inst) == "" {
|
|
||||||
inst, err = template.GetAgentInstruction(step.Role)
|
|
||||||
}
|
|
||||||
if err == nil && strings.TrimSpace(inst) != "" {
|
|
||||||
agentsBuilder.WriteString(inst)
|
|
||||||
} else {
|
|
||||||
agentsBuilder.WriteString(fmt.Sprintf("# 角色: %s (%s)\n\n", spec.Name, spec.Role))
|
|
||||||
if len(spec.Responsibilities) > 0 {
|
|
||||||
agentsBuilder.WriteString("## 核心职责\n")
|
|
||||||
for _, r := range spec.Responsibilities {
|
|
||||||
agentsBuilder.WriteString(fmt.Sprintf("- %s\n", r))
|
|
||||||
}
|
|
||||||
agentsBuilder.WriteString("\n")
|
|
||||||
}
|
|
||||||
if len(spec.ForbiddenActions) > 0 {
|
|
||||||
agentsBuilder.WriteString("## 严禁行为(红线)\n")
|
|
||||||
for _, f := range spec.ForbiddenActions {
|
|
||||||
agentsBuilder.WriteString(fmt.Sprintf("- ❌ %s\n", f))
|
|
||||||
}
|
|
||||||
agentsBuilder.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(agentsBuilder.String()), 0o644)
|
|
||||||
_ = os.WriteFile(filepath.Join(subDir, "INSTRUCTIONS.md"), []byte(agentsBuilder.String()), 0o644)
|
|
||||||
|
|
||||||
// 2. 生成 TASK.md
|
|
||||||
deliverableName := "output.md"
|
|
||||||
if step.Mode == "plan" || step.Mode == "replan" {
|
|
||||||
deliverableName = "output_plan.md"
|
|
||||||
} else if step.Mode == "review_plan" {
|
|
||||||
deliverableName = "review_notes.md"
|
|
||||||
}
|
|
||||||
|
|
||||||
var taskBuilder strings.Builder
|
|
||||||
if step.InputLayout == InputLayoutSnapshot {
|
|
||||||
if err := SnapshotInputsIntoSubdir(workDir, subDir, step.Needs, deliverableName); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
taskText := BuildContainedTaskMarkdown(step, deliverableName)
|
|
||||||
if TaskMarkdownEscapesParent(taskText) {
|
|
||||||
return fmt.Errorf("snapshot TASK.md must not reference parent paths")
|
|
||||||
}
|
|
||||||
if err := writeRegularFile(filepath.Join(subDir, "TASK.md"), []byte(taskText)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
inputRefs := map[string]any{
|
|
||||||
"step_id": step.ID,
|
|
||||||
"role": step.Role,
|
|
||||||
"needs": step.Needs,
|
|
||||||
"target_file": deliverableName,
|
|
||||||
"input_layout": InputLayoutSnapshot,
|
|
||||||
"forbid_parent_paths": true,
|
|
||||||
}
|
|
||||||
_ = writeJSON(filepath.Join(subDir, "input_refs.json"), inputRefs)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
taskBuilder.WriteString(fmt.Sprintf("# 任务执行指令: %s\n\n", step.Title))
|
|
||||||
taskBuilder.WriteString(fmt.Sprintf("- **步骤 ID**: `%s`\n", step.ID))
|
|
||||||
taskBuilder.WriteString(fmt.Sprintf("- **分配角色**: `%s`\n", step.Role))
|
|
||||||
taskBuilder.WriteString(fmt.Sprintf("- **执行模式**: `%s`\n", step.Mode))
|
|
||||||
taskBuilder.WriteString(fmt.Sprintf("- **交付产物**: `%s`\n\n", deliverableName))
|
|
||||||
|
|
||||||
taskBuilder.WriteString("## 环境与输入导航\n")
|
|
||||||
taskBuilder.WriteString("请仔细查阅上级目录资源:\n")
|
|
||||||
taskBuilder.WriteString("- `../../context/index.json` 及 `../../context/README.md`:阅读上下文指引\n")
|
|
||||||
taskBuilder.WriteString("- `../../template.md`:自描述交付模板(嵌入四段式与格式约束)\n")
|
|
||||||
taskBuilder.WriteString("- `../../blackboard.json`:前序专员抽取的结构化事实与缺口\n")
|
|
||||||
taskBuilder.WriteString("- `../../context/project.md`:项目全局立项基线与产品定义\n")
|
|
||||||
taskBuilder.WriteString("- `../../context/user.md`:用户个性化工作偏好与决策约束\n")
|
|
||||||
taskBuilder.WriteString("- `../../context/materials_text/README.md`:原始材料全文或基线说明\n")
|
|
||||||
taskBuilder.WriteString("- `../../context/knowledge/hits.md`:法规与标准命中快照 (若存在)\n\n")
|
|
||||||
|
|
||||||
if len(step.Needs) > 0 {
|
|
||||||
taskBuilder.WriteString("## 前序依赖产出\n")
|
|
||||||
for _, n := range step.Needs {
|
|
||||||
taskBuilder.WriteString(fmt.Sprintf("- `../../%s`\n", filepath.ToSlash(n)))
|
|
||||||
}
|
|
||||||
taskBuilder.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(step.Accept) > 0 {
|
|
||||||
taskBuilder.WriteString("## 验收标准(Checklist)\n")
|
|
||||||
for _, a := range step.Accept {
|
|
||||||
taskBuilder.WriteString(fmt.Sprintf("- [ ] %s\n", a))
|
|
||||||
}
|
|
||||||
taskBuilder.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(wf.TaskKey, "rd_") {
|
|
||||||
overlay := RDevStageOverlay(wf.TaskKey, step.Role, rdevTierFromWorkspace(workDir))
|
|
||||||
if overlay != "" {
|
|
||||||
_ = os.WriteFile(filepath.Join(subDir, "OVERLAY.md"), []byte(overlay+"\n"), 0o644)
|
|
||||||
taskBuilder.WriteString("## 阶段纪律(overlay)\n")
|
|
||||||
taskBuilder.WriteString("写作纪律与粒度下限见同目录 `OVERLAY.md`。模板只规定章节结构,不得用 overlay 改写章节骨架。\n\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
taskBuilder.WriteString("## 交付契约与写作纪律\n")
|
|
||||||
taskBuilder.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`:\n", deliverableName))
|
|
||||||
taskBuilder.WriteString("1. 严格四段式展开(结论句 → 表格/列表 → 解读 → 支撑出处)。\n")
|
|
||||||
taskBuilder.WriteString("2. 各章节必须实质展开;禁止以篇幅借口或省略写法跳过应写细节(自检时勿复述禁用原词)。\n")
|
|
||||||
taskBuilder.WriteString("3. 关键事实必须标注出处,未知项列入待确认清单。\n")
|
|
||||||
if step.Role == "organizer" {
|
|
||||||
taskBuilder.WriteString("4. 从材料提取的客观字段,请在当前工作目录下输出 `fields.json`。\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(taskBuilder.String()), 0o644)
|
|
||||||
|
|
||||||
// 3. 写入 input_refs.json
|
|
||||||
inputRefs := map[string]any{
|
|
||||||
"step_id": step.ID,
|
|
||||||
"role": step.Role,
|
|
||||||
"needs": step.Needs,
|
|
||||||
"target_file": deliverableName,
|
|
||||||
}
|
|
||||||
_ = writeJSON(filepath.Join(subDir, "input_refs.json"), inputRefs)
|
|
||||||
|
|
||||||
// 4. 创建便捷软链接,使得当前目录下无论是读 template.md、context/ 还是 ../../ 均可访问
|
|
||||||
_ = os.Symlink(filepath.Join("..", "..", "template.md"), filepath.Join(subDir, "template.md"))
|
|
||||||
_ = os.Symlink(filepath.Join("..", "..", "context"), filepath.Join(subDir, "context"))
|
|
||||||
_ = os.Symlink(filepath.Join("..", "..", "blackboard.json"), filepath.Join(subDir, "blackboard.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultSquadAgents 根据任务类型返回默认小队成员编制
|
|
||||||
func DefaultSquadAgents(taskKey string) []AgentSpec {
|
|
||||||
if strings.HasPrefix(taskKey, "rd_") {
|
|
||||||
return rdevSquadAgents(taskKey, "")
|
|
||||||
}
|
|
||||||
taskKey = workflowdef.ImplementationTaskKey(taskKey)
|
|
||||||
switch taskKey {
|
|
||||||
case "intake":
|
|
||||||
return []AgentSpec{
|
|
||||||
{
|
|
||||||
Role: "organizer",
|
|
||||||
Name: "材料整理专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"材料事实抽取", "缺口识别", "字段结构化回填"},
|
|
||||||
ForbiddenActions: []string{"严禁测算价格", "严禁出具法规终结裁定", "严禁撰写正文终稿"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "writer",
|
|
||||||
Name: "文档撰写专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"严格四段式成文", "依据材料出处引用", "模板各章节写满"},
|
|
||||||
ForbiddenActions: []string{"严禁以省略写法跳过应写章节", "严禁随意篡改前序事实"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "leader",
|
|
||||||
Name: "商务对接负责人",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"计划审核", "正文终审", "待确认清单汇总", "对外汇报"},
|
|
||||||
ForbiddenActions: []string{"严禁代用户做商业拍板", "严禁直接交付未审核初稿"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
case "research":
|
|
||||||
return []AgentSpec{
|
|
||||||
{
|
|
||||||
Role: "searcher",
|
|
||||||
Name: "资料检索专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"公开资料检索", "法规标准核对", "出处与日期标注"},
|
|
||||||
ForbiddenActions: []string{"严禁编造URL与标准号", "未检索到须标明尝试路径"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "market",
|
|
||||||
Name: "市场与竞品分析师",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"竞品同维度对比", "市场规模测算", "机会与风险分析"},
|
|
||||||
ForbiddenActions: []string{"严禁编造市场份额与价格", "必须采用统一对比维度"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "regulatory",
|
|
||||||
Name: "医疗器械法规专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"器械分类初筛", "注册路径比对", "关键标准提炼"},
|
|
||||||
ForbiddenActions: []string{"严禁代做行政终局裁定", "必须标注需向窗口确认"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "writer",
|
|
||||||
Name: "文档撰写专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"按计划合成调研报告", "全面四段式展开"},
|
|
||||||
ForbiddenActions: []string{"严禁以省略写法跳过应写章节"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "leader",
|
|
||||||
Name: "商务对接负责人",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"审核撰写计划", "终审报告", "质量把关"},
|
|
||||||
ForbiddenActions: []string{"严禁代用户做商业拍板"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
case "system_reqs":
|
|
||||||
return []AgentSpec{
|
|
||||||
{
|
|
||||||
Role: "organizer",
|
|
||||||
Name: "需求梳理专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"用户需求抽取", "产品定位基线对齐", "UR需求池建立"},
|
|
||||||
ForbiddenActions: []string{"严禁随意篡改原始用户需求"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "engineer",
|
|
||||||
Name: "系统工程架构师",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"系统需求分解", "验证方式定义", "接口定义", "requirements.json 导出"},
|
|
||||||
ForbiddenActions: []string{"严禁遗漏验证方式", "严禁需求编号重复"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "regulatory",
|
|
||||||
Name: "法规合规审查专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"医疗器械强标比对", "适用性与理由论证"},
|
|
||||||
ForbiddenActions: []string{"严禁引用虚构标准号"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "writer",
|
|
||||||
Name: "系统需求文档编制专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"严格按 pm_system_reqs.md 模板成文", "正文结构完整", "填满追溯表"},
|
|
||||||
ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "leader",
|
|
||||||
Name: "智能产品经理主管",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"系统需求规格书终审", "追溯一致性把关", "质量门禁验收"},
|
|
||||||
ForbiddenActions: []string{"严禁放行未覆盖验证方式的需求"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
case "test_manager":
|
|
||||||
return []AgentSpec{
|
|
||||||
{
|
|
||||||
Role: "organizer",
|
|
||||||
Name: "需求追溯分析员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"上游系统需求解析", "需求全集锁定", "追溯关系初始化"},
|
|
||||||
ForbiddenActions: []string{"严禁遗漏任一条上游需求"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "engineer",
|
|
||||||
Name: "系统测试工程师",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"系统验证用例设计", "判据与接受准则", "test_cases.json 导出"},
|
|
||||||
ForbiddenActions: []string{"严禁测试方法泛化为仅写标准号", "严禁无判定准则"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "regulatory",
|
|
||||||
Name: "试验标准审查员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"检验方法符合性评估", "检测设备与环境要求审查"},
|
|
||||||
ForbiddenActions: []string{"严禁使用过期作废标准"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "writer",
|
|
||||||
Name: "系统测试方案编制专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"严格按 tm_system_verification.md 模板成文", "用例主表翔实完整"},
|
|
||||||
ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "leader",
|
|
||||||
Name: "智能测试经理主管",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"系统验证方案终审", "全覆盖无静默遗漏门禁把关", "质量签署"},
|
|
||||||
ForbiddenActions: []string{"严禁放行覆盖率不足 100% 的方案"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return []AgentSpec{
|
|
||||||
{
|
|
||||||
Role: "organizer",
|
|
||||||
Name: "材料整理专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"事实整理与基线对齐"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "writer",
|
|
||||||
Name: "文档撰写专员",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"正文撰写"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: "leader",
|
|
||||||
Name: "商务对接负责人",
|
|
||||||
Model: "claude-3-7-sonnet",
|
|
||||||
Tools: []string{"read", "write", "edit"},
|
|
||||||
BashPermission: "deny",
|
|
||||||
Responsibilities: []string{"终审与把关"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultWorkflowSteps 根据任务类型返回最佳实践编排步骤
|
|
||||||
func DefaultWorkflowSteps(taskKey string) []StepSpec {
|
|
||||||
if strings.HasPrefix(taskKey, "rd_") {
|
|
||||||
return rdevWorkflowSteps(taskKey, "")
|
|
||||||
}
|
|
||||||
taskKey = workflowdef.ImplementationTaskKey(taskKey)
|
|
||||||
switch taskKey {
|
|
||||||
case "intake":
|
|
||||||
return []StepSpec{
|
|
||||||
{
|
|
||||||
ID: "step_01_organize",
|
|
||||||
Role: "organizer",
|
|
||||||
Title: "抽取材料事实并回填字段",
|
|
||||||
SubWorkspace: "tasks/01_organize",
|
|
||||||
Mode: "execute",
|
|
||||||
Accept: []string{
|
|
||||||
"抽取材料中的客户身份与产品定义",
|
|
||||||
"回填 fields.json 结构化数据",
|
|
||||||
"向 blackboard 写入 facts 与 gaps",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_02_draft",
|
|
||||||
Role: "writer",
|
|
||||||
Title: "撰写用户初始需求收集表初稿",
|
|
||||||
SubWorkspace: "tasks/02_draft",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/01_organize/output.md"},
|
|
||||||
Accept: []string{
|
|
||||||
"按 template.md 各章节严格四段式展开",
|
|
||||||
"正文翔实,各节须实质展开",
|
|
||||||
"文末集中附录字段快照与待确认清单",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_03_review",
|
|
||||||
Role: "leader",
|
|
||||||
Title: "审核并定稿交付",
|
|
||||||
SubWorkspace: "tasks/03_review",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/02_draft/output.md"},
|
|
||||||
Accept: []string{
|
|
||||||
"核验全文逻辑一致性与出处标注",
|
|
||||||
"汇编待确认清单并给出下一步建议",
|
|
||||||
"交付终稿 output.md",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
case "research":
|
|
||||||
return []StepSpec{
|
|
||||||
{
|
|
||||||
ID: "step_01_search",
|
|
||||||
Role: "searcher",
|
|
||||||
Title: "公开资料与标准检索",
|
|
||||||
SubWorkspace: "tasks/01_search",
|
|
||||||
Mode: "execute",
|
|
||||||
Parallel: true,
|
|
||||||
Accept: []string{
|
|
||||||
"检索纪要附带明确出处或未检索到说明",
|
|
||||||
"对本项目含义逐条阐述",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_02_market",
|
|
||||||
Role: "market",
|
|
||||||
Title: "市场格局与竞品同维度对比",
|
|
||||||
SubWorkspace: "tasks/02_market",
|
|
||||||
Mode: "execute",
|
|
||||||
Parallel: true,
|
|
||||||
Accept: []string{
|
|
||||||
"多维度竞品对比表",
|
|
||||||
"机会与风险对称分析",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_03_regulatory",
|
|
||||||
Role: "regulatory",
|
|
||||||
Title: "医疗器械法规与指导原则初筛",
|
|
||||||
SubWorkspace: "tasks/03_regulatory",
|
|
||||||
Mode: "execute",
|
|
||||||
Parallel: true,
|
|
||||||
Accept: []string{
|
|
||||||
"分类依据条目与建议类别",
|
|
||||||
"明确标注需向审评窗口确认",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_04_plan",
|
|
||||||
Role: "writer",
|
|
||||||
Title: "撰写调研报告段落计划",
|
|
||||||
SubWorkspace: "tasks/04_plan",
|
|
||||||
Mode: "plan",
|
|
||||||
Needs: []string{
|
|
||||||
"tasks/01_search/output.md",
|
|
||||||
"tasks/02_market/output.md",
|
|
||||||
"tasks/03_regulatory/output.md",
|
|
||||||
},
|
|
||||||
Accept: []string{"各章节论点与引用规划"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_05_review_plan",
|
|
||||||
Role: "leader",
|
|
||||||
Title: "审核调研报告撰写计划",
|
|
||||||
SubWorkspace: "tasks/05_review_plan",
|
|
||||||
Mode: "review_plan",
|
|
||||||
Needs: []string{"tasks/04_plan/output_plan.md"},
|
|
||||||
Accept: []string{"出具审核意见 review_notes.md"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_06_draft",
|
|
||||||
Role: "writer",
|
|
||||||
Title: "按计划合成调研报告正文",
|
|
||||||
SubWorkspace: "tasks/06_draft",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{
|
|
||||||
"tasks/04_plan/output_plan.md",
|
|
||||||
"tasks/05_review_plan/review_notes.md",
|
|
||||||
},
|
|
||||||
Accept: []string{"四段式完整正文,无省略套话"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_07_review",
|
|
||||||
Role: "leader",
|
|
||||||
Title: "终审并定稿",
|
|
||||||
SubWorkspace: "tasks/07_review",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/06_draft/output.md"},
|
|
||||||
Accept: []string{"质量把关并通过交付"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
case "feasibility_reg":
|
|
||||||
return []StepSpec{
|
|
||||||
{
|
|
||||||
ID: "step_01_regulatory",
|
|
||||||
Role: "regulatory",
|
|
||||||
Title: "分析法规分类与标准条款",
|
|
||||||
SubWorkspace: "tasks/01_regulatory",
|
|
||||||
Mode: "execute",
|
|
||||||
Accept: []string{
|
|
||||||
"研判医疗器械管理类别与分类依据",
|
|
||||||
"结合知识库 hits.md 梳理适用的强制与推荐标准",
|
|
||||||
"给出型式检验与主要检测项目清单",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_02_draft",
|
|
||||||
Role: "writer",
|
|
||||||
Title: "撰写注册可行性评估报告初稿",
|
|
||||||
SubWorkspace: "tasks/02_draft",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/01_regulatory/output.md"},
|
|
||||||
Accept: []string{
|
|
||||||
"严格按 template.md 结构展开",
|
|
||||||
"必须显式标注引用标准与条款 CiteKey",
|
|
||||||
"文末附待确认清单与风险警示",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_03_review",
|
|
||||||
Role: "leader",
|
|
||||||
Title: "审核定稿交付",
|
|
||||||
SubWorkspace: "tasks/03_review",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/02_draft/output.md"},
|
|
||||||
Accept: []string{
|
|
||||||
"核验法规条款引用的准确性与完整性",
|
|
||||||
"质量门禁达标交付",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
case "system_reqs":
|
|
||||||
return []StepSpec{
|
|
||||||
{
|
|
||||||
ID: "step_01_organize",
|
|
||||||
Role: "organizer",
|
|
||||||
Title: "解析用户需求并抽取产品功能点",
|
|
||||||
SubWorkspace: "tasks/01_organize",
|
|
||||||
Mode: "execute",
|
|
||||||
Accept: []string{
|
|
||||||
"抽取用户需求或项目事实黑板中的关键需求条目",
|
|
||||||
"提取系统功能、非功能指标及环境约束",
|
|
||||||
"向 blackboard 写入 facts 与 gaps",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_02_engineer",
|
|
||||||
Role: "engineer",
|
|
||||||
Title: "系统技术规格与性能指标分解",
|
|
||||||
SubWorkspace: "tasks/02_engineer",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/01_organize/output.md"},
|
|
||||||
Accept: []string{
|
|
||||||
"定义子系统性能、电气/软件/机械指标与参数",
|
|
||||||
"规范接口协议与环境要求",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_03_regulatory",
|
|
||||||
Role: "regulatory",
|
|
||||||
Title: "医疗器械强标比对与合规性论证",
|
|
||||||
SubWorkspace: "tasks/03_regulatory",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/01_organize/output.md"},
|
|
||||||
Accept: []string{
|
|
||||||
"审查适用医疗器械强制性标准及条款要求",
|
|
||||||
"输出安全、可靠性与合规性约束条目",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_04_draft",
|
|
||||||
Role: "writer",
|
|
||||||
Title: "编制系统需求规格书全文",
|
|
||||||
SubWorkspace: "tasks/04_draft",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{
|
|
||||||
"tasks/01_organize/output.md",
|
|
||||||
"tasks/02_engineer/output.md",
|
|
||||||
"tasks/03_regulatory/output.md",
|
|
||||||
},
|
|
||||||
Accept: []string{
|
|
||||||
"按 template.md 结构展开,包含系统需求清单与接口要求",
|
|
||||||
"每条系统需求必须具备明确唯一的 REQ- 编号与验证方式",
|
|
||||||
"禁止敷衍套话与未替换占位符",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_05_review",
|
|
||||||
Role: "leader",
|
|
||||||
Title: "终审需求可测性与可追溯性定稿",
|
|
||||||
SubWorkspace: "tasks/05_review",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/04_draft/output.md"},
|
|
||||||
Accept: []string{
|
|
||||||
"全面核查需求条目可验证性与无歧义性",
|
|
||||||
"核查追溯关系完整性并交付终稿",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
case "test_manager":
|
|
||||||
return []StepSpec{
|
|
||||||
{
|
|
||||||
ID: "step_01_trace",
|
|
||||||
Role: "organizer",
|
|
||||||
Title: "解析上游系统需求并锁定全量追溯矩阵",
|
|
||||||
SubWorkspace: "tasks/01_trace",
|
|
||||||
Mode: "execute",
|
|
||||||
Accept: []string{
|
|
||||||
"全量读取上游系统需求规格书的所有 REQ- 编号条目",
|
|
||||||
"建立 100% 覆盖的测试需求追溯矩阵底表",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_02_cases",
|
|
||||||
Role: "engineer",
|
|
||||||
Title: "设计系统测试用例与判定准则",
|
|
||||||
SubWorkspace: "tasks/02_cases",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/01_trace/output.md"},
|
|
||||||
Accept: []string{
|
|
||||||
"为每条需求设计至少一个具备明确合格判定准则的测试用例 TC-",
|
|
||||||
"导出 test_cases.json 结构化用例清单至 artifacts/",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_03_regulatory",
|
|
||||||
Role: "regulatory",
|
|
||||||
Title: "检验规程与检测环境合规审查",
|
|
||||||
SubWorkspace: "tasks/03_regulatory",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/02_cases/output.md"},
|
|
||||||
Accept: []string{
|
|
||||||
"核验测试方法与引用的医疗器械检验标准条款一致性",
|
|
||||||
"审查仪器设备与测试环境要求",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_04_draft",
|
|
||||||
Role: "writer",
|
|
||||||
Title: "编制系统验证方案及计划全文",
|
|
||||||
SubWorkspace: "tasks/04_draft",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{
|
|
||||||
"tasks/01_trace/output.md",
|
|
||||||
"tasks/02_cases/output.md",
|
|
||||||
"tasks/03_regulatory/output.md",
|
|
||||||
},
|
|
||||||
Accept: []string{
|
|
||||||
"严格按 tm_system_verification.md 模板生成正式方案文本",
|
|
||||||
"嵌入测试用例清单与覆盖率统计,严禁遗漏上游需求",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_05_review",
|
|
||||||
Role: "leader",
|
|
||||||
Title: "全覆盖门禁终审与定稿交付",
|
|
||||||
SubWorkspace: "tasks/05_review",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/04_draft/output.md"},
|
|
||||||
Accept: []string{
|
|
||||||
"验证需求覆盖率达到 100%",
|
|
||||||
"签署验证结论并交付",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return []StepSpec{
|
|
||||||
{
|
|
||||||
ID: "step_01_draft",
|
|
||||||
Role: "writer",
|
|
||||||
Title: "撰写初稿",
|
|
||||||
SubWorkspace: "tasks/01_draft",
|
|
||||||
Mode: "execute",
|
|
||||||
Accept: []string{"按模板四段式展开"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "step_02_review",
|
|
||||||
Role: "leader",
|
|
||||||
Title: "审核定稿",
|
|
||||||
SubWorkspace: "tasks/02_review",
|
|
||||||
Mode: "execute",
|
|
||||||
Needs: []string{"tasks/01_draft/output.md"},
|
|
||||||
Accept: []string{"质量门禁通过"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(path string, val any) error {
|
|
||||||
b, err := json.MarshalIndent(val, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(path, b, 0o644)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package squad
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildContextSystem 构建具有权威度说明的 context/ 体系
|
||||||
|
func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepareRequest) error {
|
||||||
|
ctxDir := filepath.Join(workDir, "context")
|
||||||
|
_ = os.MkdirAll(filepath.Join(ctxDir, "materials_text"), 0o755)
|
||||||
|
_ = os.MkdirAll(filepath.Join(ctxDir, "knowledge"), 0o755)
|
||||||
|
_ = os.MkdirAll(filepath.Join(ctxDir, "prior_docs"), 0o755)
|
||||||
|
_ = os.MkdirAll(filepath.Join(ctxDir, "memory"), 0o755)
|
||||||
|
|
||||||
|
var contextItems []ContextItem
|
||||||
|
|
||||||
|
// 1. user.md
|
||||||
|
userContent := req.UserContext
|
||||||
|
if strings.TrimSpace(userContent) == "" {
|
||||||
|
userContent = fmt.Sprintf("# 用户角色与偏好\n\n- 用户角色: 商务与技术负责人\n- 决策风格: 严谨可追溯,注重四段式论据\n- 授权边界: 预算与商业承诺必须经由用户拍板\n")
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "user.md"), []byte(userContent), 0o644)
|
||||||
|
contextItems = append(contextItems, ContextItem{
|
||||||
|
File: "context/user.md",
|
||||||
|
Description: "操作用户的业务角色、决策授权边界与协同偏好",
|
||||||
|
AuthorityLevel: "authoritative",
|
||||||
|
Guidance: "必须尊重用户设定的目标市场与边界,用户未决事项一律列入待确认清单,严禁越权替用户拍板",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. project.md
|
||||||
|
projContent := req.ProjectContext
|
||||||
|
if strings.TrimSpace(projContent) == "" {
|
||||||
|
projContent = fmt.Sprintf("# 项目全局基线\n\n- 项目名称: %s\n- 客户名称: %s\n- 产品类型: %s\n- 预期用途: %s\n- 目标市场: %s\n",
|
||||||
|
req.ProjectName, req.CustomerName, req.DeviceType, req.IntendedUse, req.TargetMarket)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "project.md"), []byte(projContent), 0o644)
|
||||||
|
contextItems = append(contextItems, ContextItem{
|
||||||
|
File: "context/project.md",
|
||||||
|
Description: "项目立项章程与全局事实基线",
|
||||||
|
AuthorityLevel: "authoritative",
|
||||||
|
Guidance: "本项目核心前提。所有后续分析论证不得推翻已锁定的项目名称与核心预期用途",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. agent.md
|
||||||
|
agentContent := req.AgentContext
|
||||||
|
if strings.TrimSpace(agentContent) == "" {
|
||||||
|
agentContent = "# 智能体小队协同通用规范\n\n- 事实闭环: 严禁凭空捏造未提供之参数或法规\n- 引用规范: 引用必须标注出处〔材料:...〕或〔法规:...〕\n- 负向红线: 禁止敷衍写作;各章节须实质展开,不得以篇幅或省略写法跳过应写内容\n"
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "agent.md"), []byte(agentContent), 0o644)
|
||||||
|
contextItems = append(contextItems, ContextItem{
|
||||||
|
File: "context/agent.md",
|
||||||
|
Description: "智能体通用协同纪律与引用要求",
|
||||||
|
AuthorityLevel: "authoritative",
|
||||||
|
Guidance: "所有小队角色必须严格执行的专业守则与写作禁令",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. materials_text/
|
||||||
|
if len(req.Materials) > 0 {
|
||||||
|
var allBuilder strings.Builder
|
||||||
|
for _, m := range req.Materials {
|
||||||
|
name := filepath.Base(m.Filename)
|
||||||
|
if name == "" || name == "." {
|
||||||
|
name = "material.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动提取纯文本并注入页码锚点 <!-- page N -->
|
||||||
|
textContent, err := ExtractTextWithAnchors(name, m.Content)
|
||||||
|
if err != nil || strings.TrimSpace(textContent) == "" {
|
||||||
|
textContent = string(m.Content)
|
||||||
|
}
|
||||||
|
textBytes := []byte(textContent)
|
||||||
|
|
||||||
|
p := filepath.Join(ctxDir, "materials_text", name)
|
||||||
|
_ = os.WriteFile(p, textBytes, 0o644)
|
||||||
|
// 若原文件扩展名不是 .txt / .md,同时生成一份 .txt 方便智能体直接阅读
|
||||||
|
if !strings.HasSuffix(name, ".txt") && !strings.HasSuffix(name, ".md") {
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "materials_text", name+".txt"), textBytes, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also write to root materials_text/ for backward compatibility
|
||||||
|
rootMatDir := filepath.Join(workDir, "materials_text")
|
||||||
|
_ = os.MkdirAll(rootMatDir, 0o755)
|
||||||
|
_ = os.WriteFile(filepath.Join(rootMatDir, name), textBytes, 0o644)
|
||||||
|
if !strings.HasSuffix(name, ".txt") && !strings.HasSuffix(name, ".md") {
|
||||||
|
_ = os.WriteFile(filepath.Join(rootMatDir, name+".txt"), textBytes, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
allBuilder.WriteString(fmt.Sprintf("\n\n=== 文件: %s ===\n\n", name))
|
||||||
|
allBuilder.WriteString(textContent)
|
||||||
|
|
||||||
|
contextItems = append(contextItems, ContextItem{
|
||||||
|
File: filepath.ToSlash(filepath.Join("context", "materials_text", name)),
|
||||||
|
Description: fmt.Sprintf("用户上传的输入材料文本: %s", name),
|
||||||
|
AuthorityLevel: "primary_evidence",
|
||||||
|
Guidance: "核心原始证据。提取参数需标明出处;材料内部矛盾需在矛盾表中并列呈现",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "_all.txt"), []byte(allBuilder.String()), 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "README.md"), []byte("# 原始材料清单\n\n已汇总在 `context/materials_text/_all.txt`,亦可单独查阅各文件。\n"), 0o644)
|
||||||
|
} else {
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "materials_text", "README.md"), []byte("# 原始材料说明\n\n本项目当前未提供独立的附加文本材料,所有事实依据以 `context/project.md`(项目全局基线)、`context/user.md`(用户要求与偏好)以及 `context/knowledge/hits.md`(法规标准知识库)为准。\n"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. knowledge/
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "knowledge", "WEB_CROSSCHECK.md"), []byte("# 法规检索与交叉核对原则\n\n1. 知识库条目(hits.md)为首要法规事实依据。\n2. 如需查询外部信息,以国家药监局官方分类目录为主。\n"), 0o644)
|
||||||
|
if len(req.KnowledgeHits) > 0 {
|
||||||
|
var kbBuilder strings.Builder
|
||||||
|
kbBuilder.WriteString("# 权威法规与技术标准引用库 (Regulatory Knowledge Hits)\n\n")
|
||||||
|
kbBuilder.WriteString("以下为针对本项目研发与注册需求命中的权威法规、国家标准(GB)与行业标准(YY)条款。\n")
|
||||||
|
kbBuilder.WriteString("在输出交付物时,请结合上下文使用,并显式标注【引用标记 (CiteKey)】标明出处。\n\n")
|
||||||
|
for _, hit := range req.KnowledgeHits {
|
||||||
|
kbBuilder.WriteString(fmt.Sprintf("### [%s] %s %s\n\n", strings.ToUpper(hit.Category), hit.DocCode, hit.Title))
|
||||||
|
if hit.HeadingPath != "" {
|
||||||
|
kbBuilder.WriteString(fmt.Sprintf("- **章节路径**: %s\n", hit.HeadingPath))
|
||||||
|
}
|
||||||
|
if hit.CiteKey != "" {
|
||||||
|
kbBuilder.WriteString(fmt.Sprintf("- **引用标记 (CiteKey)**: `%s`\n", hit.CiteKey))
|
||||||
|
}
|
||||||
|
kbBuilder.WriteString(fmt.Sprintf("- **来源**: %s\n\n", hit.Source))
|
||||||
|
kbBuilder.WriteString(hit.Content + "\n\n---\n\n")
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "knowledge", "hits.md"), []byte(kbBuilder.String()), 0o644)
|
||||||
|
contextItems = append(contextItems, ContextItem{
|
||||||
|
File: "context/knowledge/hits.md",
|
||||||
|
Description: "法规知识库中命中的医疗器械分类、国家标准与指导原则",
|
||||||
|
AuthorityLevel: "reference_standard",
|
||||||
|
Guidance: "合规与技术论据源。引用必须标注标准号与引用标记 (CiteKey);涉及审批结论需标注'需向药监局审评中心确认'",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. prior_docs/
|
||||||
|
if len(req.PriorDocs) > 0 {
|
||||||
|
var indexBuilder strings.Builder
|
||||||
|
indexBuilder.WriteString("# 前序阶段交付物索引\n\n")
|
||||||
|
for _, doc := range req.PriorDocs {
|
||||||
|
filename := doc.Filename
|
||||||
|
if filename == "" {
|
||||||
|
filename = doc.DocKey + ".md"
|
||||||
|
}
|
||||||
|
p := filepath.Join(ctxDir, "prior_docs", filename)
|
||||||
|
_ = os.WriteFile(p, []byte(doc.Content), 0o644)
|
||||||
|
indexBuilder.WriteString(fmt.Sprintf("- [%s] `%s`: %s (阶段: %s)\n", doc.DocKey, filename, doc.Title, doc.Stage))
|
||||||
|
|
||||||
|
contextItems = append(contextItems, ContextItem{
|
||||||
|
File: filepath.ToSlash(filepath.Join("context", "prior_docs", filename)),
|
||||||
|
Description: fmt.Sprintf("前序已归档文档: %s (%s)", doc.Title, doc.DocKey),
|
||||||
|
AuthorityLevel: "primary_evidence",
|
||||||
|
Guidance: "前序阶段已批准的结论基准。本阶段内容应与前序结论保持自洽",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "prior_docs", "index.md"), []byte(indexBuilder.String()), 0o644)
|
||||||
|
} else {
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "prior_docs", "README.md"), []byte("# 前序交付物说明\n\n本项目当前处于启动阶段,暂无已归档的前序阶段交付物。\n"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TemplateContent != "" {
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "template.md"), []byte(req.TemplateContent), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. memory/
|
||||||
|
if len(req.MemoryBankFiles) > 0 {
|
||||||
|
for k, v := range req.MemoryBankFiles {
|
||||||
|
p := filepath.Join(ctxDir, "memory", k)
|
||||||
|
_ = os.WriteFile(p, []byte(v), 0o644)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 默认四大核心资产骨架
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "memory", "projectbrief.md"), []byte(fmt.Sprintf("# 项目章程\n- 项目: %s\n- 客户: %s\n", req.ProjectName, req.CustomerName)), 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "memory", "activeContext.md"), []byte(fmt.Sprintf("# 当前焦点\n- 阶段: %s\n- 时间: %s\n", req.TaskKey, time.Now().Format("2006-01-02 15:04"))), 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "memory", "systemPatterns.md"), []byte("# 规范与模式\n- 遵循四段式结构与严格角色边界\n- 单一真实源原则\n"), 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(ctxDir, "memory", "progress.md"), []byte(fmt.Sprintf("# 进度\n- 当前正在进行: %s\n", req.StageTitle)), 0o644)
|
||||||
|
}
|
||||||
|
contextItems = append(contextItems, ContextItem{
|
||||||
|
File: "context/memory/",
|
||||||
|
Description: "长期项目记忆银行 (ActiveContext, Progress, ProjectBrief, SystemPatterns)",
|
||||||
|
AuthorityLevel: "supplementary",
|
||||||
|
Guidance: "协同上下文背景参考,防止上下文遗忘",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 8. 写入 context/index.json
|
||||||
|
ctxIndex := ContextIndex{Items: contextItems}
|
||||||
|
if err := writeJSON(filepath.Join(ctxDir, "index.json"), ctxIndex); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 写入 context/README.md 指引
|
||||||
|
readmeContent := `# 上下文使用指引 (Context Reading Guide)
|
||||||
|
|
||||||
|
本目录为当前智能体任务的自包含上下文沙箱。所有智能体专员在执行任务时,必须按照以下权威度等级(Authority Level)采纳信息:
|
||||||
|
|
||||||
|
| 权威度级别 | 适用文件类别 | 采信与处理规则 |
|
||||||
|
|---|---|---|
|
||||||
|
| **authoritative** | ` + "`user.md`" + `, ` + "`project.md`" + `, ` + "`agent.md`" + ` | **最高权威**。必须绝对遵循;严禁越权替用户拍板,未决项列入待确认清单 |
|
||||||
|
| **primary_evidence** | ` + "`materials_text/*`" + `, ` + "`prior_docs/*`" + ` | **原始事实证据**。引用必须标明出处条文;若材料间存在矛盾,并列呈现 |
|
||||||
|
| **reference_standard** | ` + "`knowledge/hits.md`" + ` | **标准与法规依据**。必须指明法规/标准代码;涉及监管裁定标注需窗口核实 |
|
||||||
|
| **supplementary** | ` + "`memory/*`" + ` | **补充记忆背景**。仅供背景了解,不作为唯一支撑证据 |
|
||||||
|
|
||||||
|
---
|
||||||
|
`
|
||||||
|
return os.WriteFile(filepath.Join(ctxDir, "README.md"), []byte(readmeContent), 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package squad
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/workflowdef"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultSquadAgents 根据任务类型返回默认小队成员编制
|
||||||
|
func DefaultSquadAgents(taskKey string) []AgentSpec {
|
||||||
|
if strings.HasPrefix(taskKey, "rd_") {
|
||||||
|
return rdevSquadAgents(taskKey, "")
|
||||||
|
}
|
||||||
|
taskKey = workflowdef.ImplementationTaskKey(taskKey)
|
||||||
|
switch taskKey {
|
||||||
|
case "intake":
|
||||||
|
return []AgentSpec{
|
||||||
|
{
|
||||||
|
Role: "organizer",
|
||||||
|
Name: "材料整理专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"材料事实抽取", "缺口识别", "字段结构化回填"},
|
||||||
|
ForbiddenActions: []string{"严禁测算价格", "严禁出具法规终结裁定", "严禁撰写正文终稿"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "writer",
|
||||||
|
Name: "文档撰写专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"严格四段式成文", "依据材料出处引用", "模板各章节写满"},
|
||||||
|
ForbiddenActions: []string{"严禁以省略写法跳过应写章节", "严禁随意篡改前序事实"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "leader",
|
||||||
|
Name: "商务对接负责人",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"计划审核", "正文终审", "待确认清单汇总", "对外汇报"},
|
||||||
|
ForbiddenActions: []string{"严禁代用户做商业拍板", "严禁直接交付未审核初稿"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "research":
|
||||||
|
return []AgentSpec{
|
||||||
|
{
|
||||||
|
Role: "searcher",
|
||||||
|
Name: "资料检索专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"公开资料检索", "法规标准核对", "出处与日期标注"},
|
||||||
|
ForbiddenActions: []string{"严禁编造URL与标准号", "未检索到须标明尝试路径"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "market",
|
||||||
|
Name: "市场与竞品分析师",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"竞品同维度对比", "市场规模测算", "机会与风险分析"},
|
||||||
|
ForbiddenActions: []string{"严禁编造市场份额与价格", "必须采用统一对比维度"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "regulatory",
|
||||||
|
Name: "医疗器械法规专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"器械分类初筛", "注册路径比对", "关键标准提炼"},
|
||||||
|
ForbiddenActions: []string{"严禁代做行政终局裁定", "必须标注需向窗口确认"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "writer",
|
||||||
|
Name: "文档撰写专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"按计划合成调研报告", "全面四段式展开"},
|
||||||
|
ForbiddenActions: []string{"严禁以省略写法跳过应写章节"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "leader",
|
||||||
|
Name: "商务对接负责人",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"审核撰写计划", "终审报告", "质量把关"},
|
||||||
|
ForbiddenActions: []string{"严禁代用户做商业拍板"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "system_reqs":
|
||||||
|
return []AgentSpec{
|
||||||
|
{
|
||||||
|
Role: "organizer",
|
||||||
|
Name: "需求梳理专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"用户需求抽取", "产品定位基线对齐", "UR需求池建立"},
|
||||||
|
ForbiddenActions: []string{"严禁随意篡改原始用户需求"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "engineer",
|
||||||
|
Name: "系统工程架构师",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"系统需求分解", "验证方式定义", "接口定义", "requirements.json 导出"},
|
||||||
|
ForbiddenActions: []string{"严禁遗漏验证方式", "严禁需求编号重复"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "regulatory",
|
||||||
|
Name: "法规合规审查专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"医疗器械强标比对", "适用性与理由论证"},
|
||||||
|
ForbiddenActions: []string{"严禁引用虚构标准号"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "writer",
|
||||||
|
Name: "系统需求文档编制专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"严格按 pm_system_reqs.md 模板成文", "正文结构完整", "填满追溯表"},
|
||||||
|
ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "leader",
|
||||||
|
Name: "智能产品经理主管",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"系统需求规格书终审", "追溯一致性把关", "质量门禁验收"},
|
||||||
|
ForbiddenActions: []string{"严禁放行未覆盖验证方式的需求"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "test_manager":
|
||||||
|
return []AgentSpec{
|
||||||
|
{
|
||||||
|
Role: "organizer",
|
||||||
|
Name: "需求追溯分析员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"上游系统需求解析", "需求全集锁定", "追溯关系初始化"},
|
||||||
|
ForbiddenActions: []string{"严禁遗漏任一条上游需求"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "engineer",
|
||||||
|
Name: "系统测试工程师",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"系统验证用例设计", "判据与接受准则", "test_cases.json 导出"},
|
||||||
|
ForbiddenActions: []string{"严禁测试方法泛化为仅写标准号", "严禁无判定准则"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "regulatory",
|
||||||
|
Name: "试验标准审查员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"检验方法符合性评估", "检测设备与环境要求审查"},
|
||||||
|
ForbiddenActions: []string{"严禁使用过期作废标准"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "writer",
|
||||||
|
Name: "系统测试方案编制专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"严格按 tm_system_verification.md 模板成文", "用例主表翔实完整"},
|
||||||
|
ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "leader",
|
||||||
|
Name: "智能测试经理主管",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"系统验证方案终审", "全覆盖无静默遗漏门禁把关", "质量签署"},
|
||||||
|
ForbiddenActions: []string{"严禁放行覆盖率不足 100% 的方案"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return []AgentSpec{
|
||||||
|
{
|
||||||
|
Role: "organizer",
|
||||||
|
Name: "材料整理专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"事实整理与基线对齐"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "writer",
|
||||||
|
Name: "文档撰写专员",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"正文撰写"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "leader",
|
||||||
|
Name: "商务对接负责人",
|
||||||
|
Model: "claude-3-7-sonnet",
|
||||||
|
Tools: []string{"read", "write", "edit"},
|
||||||
|
BashPermission: "deny",
|
||||||
|
Responsibilities: []string{"终审与把关"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
package squad
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/workflowdef"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultWorkflowSteps 根据任务类型返回最佳实践编排步骤
|
||||||
|
func DefaultWorkflowSteps(taskKey string) []StepSpec {
|
||||||
|
if strings.HasPrefix(taskKey, "rd_") {
|
||||||
|
return rdevWorkflowSteps(taskKey, "")
|
||||||
|
}
|
||||||
|
taskKey = workflowdef.ImplementationTaskKey(taskKey)
|
||||||
|
switch taskKey {
|
||||||
|
case "intake":
|
||||||
|
return []StepSpec{
|
||||||
|
{
|
||||||
|
ID: "step_01_organize",
|
||||||
|
Role: "organizer",
|
||||||
|
Title: "抽取材料事实并回填字段",
|
||||||
|
SubWorkspace: "tasks/01_organize",
|
||||||
|
Mode: "execute",
|
||||||
|
Accept: []string{
|
||||||
|
"抽取材料中的客户身份与产品定义",
|
||||||
|
"回填 fields.json 结构化数据",
|
||||||
|
"向 blackboard 写入 facts 与 gaps",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_02_draft",
|
||||||
|
Role: "writer",
|
||||||
|
Title: "撰写用户初始需求收集表初稿",
|
||||||
|
SubWorkspace: "tasks/02_draft",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/01_organize/output.md"},
|
||||||
|
Accept: []string{
|
||||||
|
"按 template.md 各章节严格四段式展开",
|
||||||
|
"正文翔实,各节须实质展开",
|
||||||
|
"文末集中附录字段快照与待确认清单",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_03_review",
|
||||||
|
Role: "leader",
|
||||||
|
Title: "审核并定稿交付",
|
||||||
|
SubWorkspace: "tasks/03_review",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/02_draft/output.md"},
|
||||||
|
Accept: []string{
|
||||||
|
"核验全文逻辑一致性与出处标注",
|
||||||
|
"汇编待确认清单并给出下一步建议",
|
||||||
|
"交付终稿 output.md",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "research":
|
||||||
|
return []StepSpec{
|
||||||
|
{
|
||||||
|
ID: "step_01_search",
|
||||||
|
Role: "searcher",
|
||||||
|
Title: "公开资料与标准检索",
|
||||||
|
SubWorkspace: "tasks/01_search",
|
||||||
|
Mode: "execute",
|
||||||
|
Parallel: true,
|
||||||
|
Accept: []string{
|
||||||
|
"检索纪要附带明确出处或未检索到说明",
|
||||||
|
"对本项目含义逐条阐述",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_02_market",
|
||||||
|
Role: "market",
|
||||||
|
Title: "市场格局与竞品同维度对比",
|
||||||
|
SubWorkspace: "tasks/02_market",
|
||||||
|
Mode: "execute",
|
||||||
|
Parallel: true,
|
||||||
|
Accept: []string{
|
||||||
|
"多维度竞品对比表",
|
||||||
|
"机会与风险对称分析",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_03_regulatory",
|
||||||
|
Role: "regulatory",
|
||||||
|
Title: "医疗器械法规与指导原则初筛",
|
||||||
|
SubWorkspace: "tasks/03_regulatory",
|
||||||
|
Mode: "execute",
|
||||||
|
Parallel: true,
|
||||||
|
Accept: []string{
|
||||||
|
"分类依据条目与建议类别",
|
||||||
|
"明确标注需向审评窗口确认",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_04_plan",
|
||||||
|
Role: "writer",
|
||||||
|
Title: "撰写调研报告段落计划",
|
||||||
|
SubWorkspace: "tasks/04_plan",
|
||||||
|
Mode: "plan",
|
||||||
|
Needs: []string{
|
||||||
|
"tasks/01_search/output.md",
|
||||||
|
"tasks/02_market/output.md",
|
||||||
|
"tasks/03_regulatory/output.md",
|
||||||
|
},
|
||||||
|
Accept: []string{"各章节论点与引用规划"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_05_review_plan",
|
||||||
|
Role: "leader",
|
||||||
|
Title: "审核调研报告撰写计划",
|
||||||
|
SubWorkspace: "tasks/05_review_plan",
|
||||||
|
Mode: "review_plan",
|
||||||
|
Needs: []string{"tasks/04_plan/output_plan.md"},
|
||||||
|
Accept: []string{"出具审核意见 review_notes.md"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_06_draft",
|
||||||
|
Role: "writer",
|
||||||
|
Title: "按计划合成调研报告正文",
|
||||||
|
SubWorkspace: "tasks/06_draft",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{
|
||||||
|
"tasks/04_plan/output_plan.md",
|
||||||
|
"tasks/05_review_plan/review_notes.md",
|
||||||
|
},
|
||||||
|
Accept: []string{"四段式完整正文,无省略套话"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_07_review",
|
||||||
|
Role: "leader",
|
||||||
|
Title: "终审并定稿",
|
||||||
|
SubWorkspace: "tasks/07_review",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/06_draft/output.md"},
|
||||||
|
Accept: []string{"质量把关并通过交付"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "feasibility_reg":
|
||||||
|
return []StepSpec{
|
||||||
|
{
|
||||||
|
ID: "step_01_regulatory",
|
||||||
|
Role: "regulatory",
|
||||||
|
Title: "分析法规分类与标准条款",
|
||||||
|
SubWorkspace: "tasks/01_regulatory",
|
||||||
|
Mode: "execute",
|
||||||
|
Accept: []string{
|
||||||
|
"研判医疗器械管理类别与分类依据",
|
||||||
|
"结合知识库 hits.md 梳理适用的强制与推荐标准",
|
||||||
|
"给出型式检验与主要检测项目清单",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_02_draft",
|
||||||
|
Role: "writer",
|
||||||
|
Title: "撰写注册可行性评估报告初稿",
|
||||||
|
SubWorkspace: "tasks/02_draft",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/01_regulatory/output.md"},
|
||||||
|
Accept: []string{
|
||||||
|
"严格按 template.md 结构展开",
|
||||||
|
"必须显式标注引用标准与条款 CiteKey",
|
||||||
|
"文末附待确认清单与风险警示",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_03_review",
|
||||||
|
Role: "leader",
|
||||||
|
Title: "审核定稿交付",
|
||||||
|
SubWorkspace: "tasks/03_review",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/02_draft/output.md"},
|
||||||
|
Accept: []string{
|
||||||
|
"核验法规条款引用的准确性与完整性",
|
||||||
|
"质量门禁达标交付",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "system_reqs":
|
||||||
|
return []StepSpec{
|
||||||
|
{
|
||||||
|
ID: "step_01_organize",
|
||||||
|
Role: "organizer",
|
||||||
|
Title: "解析用户需求并抽取产品功能点",
|
||||||
|
SubWorkspace: "tasks/01_organize",
|
||||||
|
Mode: "execute",
|
||||||
|
Accept: []string{
|
||||||
|
"抽取用户需求或项目事实黑板中的关键需求条目",
|
||||||
|
"提取系统功能、非功能指标及环境约束",
|
||||||
|
"向 blackboard 写入 facts 与 gaps",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_02_engineer",
|
||||||
|
Role: "engineer",
|
||||||
|
Title: "系统技术规格与性能指标分解",
|
||||||
|
SubWorkspace: "tasks/02_engineer",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/01_organize/output.md"},
|
||||||
|
Accept: []string{
|
||||||
|
"定义子系统性能、电气/软件/机械指标与参数",
|
||||||
|
"规范接口协议与环境要求",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_03_regulatory",
|
||||||
|
Role: "regulatory",
|
||||||
|
Title: "医疗器械强标比对与合规性论证",
|
||||||
|
SubWorkspace: "tasks/03_regulatory",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/01_organize/output.md"},
|
||||||
|
Accept: []string{
|
||||||
|
"审查适用医疗器械强制性标准及条款要求",
|
||||||
|
"输出安全、可靠性与合规性约束条目",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_04_draft",
|
||||||
|
Role: "writer",
|
||||||
|
Title: "编制系统需求规格书全文",
|
||||||
|
SubWorkspace: "tasks/04_draft",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{
|
||||||
|
"tasks/01_organize/output.md",
|
||||||
|
"tasks/02_engineer/output.md",
|
||||||
|
"tasks/03_regulatory/output.md",
|
||||||
|
},
|
||||||
|
Accept: []string{
|
||||||
|
"按 template.md 结构展开,包含系统需求清单与接口要求",
|
||||||
|
"每条系统需求必须具备明确唯一的 REQ- 编号与验证方式",
|
||||||
|
"禁止敷衍套话与未替换占位符",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_05_review",
|
||||||
|
Role: "leader",
|
||||||
|
Title: "终审需求可测性与可追溯性定稿",
|
||||||
|
SubWorkspace: "tasks/05_review",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/04_draft/output.md"},
|
||||||
|
Accept: []string{
|
||||||
|
"全面核查需求条目可验证性与无歧义性",
|
||||||
|
"核查追溯关系完整性并交付终稿",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "test_manager":
|
||||||
|
return []StepSpec{
|
||||||
|
{
|
||||||
|
ID: "step_01_trace",
|
||||||
|
Role: "organizer",
|
||||||
|
Title: "解析上游系统需求并锁定全量追溯矩阵",
|
||||||
|
SubWorkspace: "tasks/01_trace",
|
||||||
|
Mode: "execute",
|
||||||
|
Accept: []string{
|
||||||
|
"全量读取上游系统需求规格书的所有 REQ- 编号条目",
|
||||||
|
"建立 100% 覆盖的测试需求追溯矩阵底表",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_02_cases",
|
||||||
|
Role: "engineer",
|
||||||
|
Title: "设计系统测试用例与判定准则",
|
||||||
|
SubWorkspace: "tasks/02_cases",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/01_trace/output.md"},
|
||||||
|
Accept: []string{
|
||||||
|
"为每条需求设计至少一个具备明确合格判定准则的测试用例 TC-",
|
||||||
|
"导出 test_cases.json 结构化用例清单至 artifacts/",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_03_regulatory",
|
||||||
|
Role: "regulatory",
|
||||||
|
Title: "检验规程与检测环境合规审查",
|
||||||
|
SubWorkspace: "tasks/03_regulatory",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/02_cases/output.md"},
|
||||||
|
Accept: []string{
|
||||||
|
"核验测试方法与引用的医疗器械检验标准条款一致性",
|
||||||
|
"审查仪器设备与测试环境要求",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_04_draft",
|
||||||
|
Role: "writer",
|
||||||
|
Title: "编制系统验证方案及计划全文",
|
||||||
|
SubWorkspace: "tasks/04_draft",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{
|
||||||
|
"tasks/01_trace/output.md",
|
||||||
|
"tasks/02_cases/output.md",
|
||||||
|
"tasks/03_regulatory/output.md",
|
||||||
|
},
|
||||||
|
Accept: []string{
|
||||||
|
"严格按 tm_system_verification.md 模板生成正式方案文本",
|
||||||
|
"嵌入测试用例清单与覆盖率统计,严禁遗漏上游需求",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_05_review",
|
||||||
|
Role: "leader",
|
||||||
|
Title: "全覆盖门禁终审与定稿交付",
|
||||||
|
SubWorkspace: "tasks/05_review",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/04_draft/output.md"},
|
||||||
|
Accept: []string{
|
||||||
|
"验证需求覆盖率达到 100%",
|
||||||
|
"签署验证结论并交付",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return []StepSpec{
|
||||||
|
{
|
||||||
|
ID: "step_01_draft",
|
||||||
|
Role: "writer",
|
||||||
|
Title: "撰写初稿",
|
||||||
|
SubWorkspace: "tasks/01_draft",
|
||||||
|
Mode: "execute",
|
||||||
|
Accept: []string{"按模板四段式展开"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "step_02_review",
|
||||||
|
Role: "leader",
|
||||||
|
Title: "审核定稿",
|
||||||
|
SubWorkspace: "tasks/02_review",
|
||||||
|
Mode: "execute",
|
||||||
|
Needs: []string{"tasks/01_draft/output.md"},
|
||||||
|
Accept: []string{"质量门禁通过"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package squad
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(path string, val any) error {
|
||||||
|
b, err := json.MarshalIndent(val, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, b, 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package squad
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildSubWorkspaces 为 workflow 中的各个子任务构建独立沙箱
|
||||||
|
func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, squadCfg SquadConfig, renderedTmpl string) error {
|
||||||
|
agentMap := make(map[string]AgentSpec)
|
||||||
|
for _, a := range squadCfg.Agents {
|
||||||
|
agentMap[a.Role] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, step := range wf.Steps {
|
||||||
|
subDir := filepath.Join(workDir, filepath.FromSlash(step.SubWorkspace))
|
||||||
|
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 生成 AGENTS.md:优先 AgentSpec.InstructionsFile,再按角色名回落,再 inline 渲染
|
||||||
|
var agentsBuilder strings.Builder
|
||||||
|
spec := agentMap[step.Role]
|
||||||
|
var inst string
|
||||||
|
var err error
|
||||||
|
if file := strings.TrimSpace(spec.InstructionsFile); file != "" {
|
||||||
|
inst, err = template.GetAgentInstructionFile(file)
|
||||||
|
}
|
||||||
|
if err != nil || strings.TrimSpace(inst) == "" {
|
||||||
|
inst, err = template.GetAgentInstruction(step.Role)
|
||||||
|
}
|
||||||
|
if err == nil && strings.TrimSpace(inst) != "" {
|
||||||
|
agentsBuilder.WriteString(inst)
|
||||||
|
} else {
|
||||||
|
agentsBuilder.WriteString(fmt.Sprintf("# 角色: %s (%s)\n\n", spec.Name, spec.Role))
|
||||||
|
if len(spec.Responsibilities) > 0 {
|
||||||
|
agentsBuilder.WriteString("## 核心职责\n")
|
||||||
|
for _, r := range spec.Responsibilities {
|
||||||
|
agentsBuilder.WriteString(fmt.Sprintf("- %s\n", r))
|
||||||
|
}
|
||||||
|
agentsBuilder.WriteString("\n")
|
||||||
|
}
|
||||||
|
if len(spec.ForbiddenActions) > 0 {
|
||||||
|
agentsBuilder.WriteString("## 严禁行为(红线)\n")
|
||||||
|
for _, f := range spec.ForbiddenActions {
|
||||||
|
agentsBuilder.WriteString(fmt.Sprintf("- ❌ %s\n", f))
|
||||||
|
}
|
||||||
|
agentsBuilder.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(agentsBuilder.String()), 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(subDir, "INSTRUCTIONS.md"), []byte(agentsBuilder.String()), 0o644)
|
||||||
|
|
||||||
|
// 2. 生成 TASK.md
|
||||||
|
deliverableName := "output.md"
|
||||||
|
if step.Mode == "plan" || step.Mode == "replan" {
|
||||||
|
deliverableName = "output_plan.md"
|
||||||
|
} else if step.Mode == "review_plan" {
|
||||||
|
deliverableName = "review_notes.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskBuilder strings.Builder
|
||||||
|
if step.InputLayout == InputLayoutSnapshot {
|
||||||
|
if err := SnapshotInputsIntoSubdir(workDir, subDir, step.Needs, deliverableName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
taskText := BuildContainedTaskMarkdown(step, deliverableName)
|
||||||
|
if TaskMarkdownEscapesParent(taskText) {
|
||||||
|
return fmt.Errorf("snapshot TASK.md must not reference parent paths")
|
||||||
|
}
|
||||||
|
if err := writeRegularFile(filepath.Join(subDir, "TASK.md"), []byte(taskText)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
inputRefs := map[string]any{
|
||||||
|
"step_id": step.ID,
|
||||||
|
"role": step.Role,
|
||||||
|
"needs": step.Needs,
|
||||||
|
"target_file": deliverableName,
|
||||||
|
"input_layout": InputLayoutSnapshot,
|
||||||
|
"forbid_parent_paths": true,
|
||||||
|
}
|
||||||
|
_ = writeJSON(filepath.Join(subDir, "input_refs.json"), inputRefs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
taskBuilder.WriteString(fmt.Sprintf("# 任务执行指令: %s\n\n", step.Title))
|
||||||
|
taskBuilder.WriteString(fmt.Sprintf("- **步骤 ID**: `%s`\n", step.ID))
|
||||||
|
taskBuilder.WriteString(fmt.Sprintf("- **分配角色**: `%s`\n", step.Role))
|
||||||
|
taskBuilder.WriteString(fmt.Sprintf("- **执行模式**: `%s`\n", step.Mode))
|
||||||
|
taskBuilder.WriteString(fmt.Sprintf("- **交付产物**: `%s`\n\n", deliverableName))
|
||||||
|
|
||||||
|
taskBuilder.WriteString("## 环境与输入导航\n")
|
||||||
|
taskBuilder.WriteString("请仔细查阅上级目录资源:\n")
|
||||||
|
taskBuilder.WriteString("- `../../context/index.json` 及 `../../context/README.md`:阅读上下文指引\n")
|
||||||
|
taskBuilder.WriteString("- `../../template.md`:自描述交付模板(嵌入四段式与格式约束)\n")
|
||||||
|
taskBuilder.WriteString("- `../../blackboard.json`:前序专员抽取的结构化事实与缺口\n")
|
||||||
|
taskBuilder.WriteString("- `../../context/project.md`:项目全局立项基线与产品定义\n")
|
||||||
|
taskBuilder.WriteString("- `../../context/user.md`:用户个性化工作偏好与决策约束\n")
|
||||||
|
taskBuilder.WriteString("- `../../context/materials_text/README.md`:原始材料全文或基线说明\n")
|
||||||
|
taskBuilder.WriteString("- `../../context/knowledge/hits.md`:法规与标准命中快照 (若存在)\n\n")
|
||||||
|
|
||||||
|
if len(step.Needs) > 0 {
|
||||||
|
taskBuilder.WriteString("## 前序依赖产出\n")
|
||||||
|
for _, n := range step.Needs {
|
||||||
|
taskBuilder.WriteString(fmt.Sprintf("- `../../%s`\n", filepath.ToSlash(n)))
|
||||||
|
}
|
||||||
|
taskBuilder.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(step.Accept) > 0 {
|
||||||
|
taskBuilder.WriteString("## 验收标准(Checklist)\n")
|
||||||
|
for _, a := range step.Accept {
|
||||||
|
taskBuilder.WriteString(fmt.Sprintf("- [ ] %s\n", a))
|
||||||
|
}
|
||||||
|
taskBuilder.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(wf.TaskKey, "rd_") {
|
||||||
|
overlay := RDevStageOverlay(wf.TaskKey, step.Role, rdevTierFromWorkspace(workDir))
|
||||||
|
if overlay != "" {
|
||||||
|
_ = os.WriteFile(filepath.Join(subDir, "OVERLAY.md"), []byte(overlay+"\n"), 0o644)
|
||||||
|
taskBuilder.WriteString("## 阶段纪律(overlay)\n")
|
||||||
|
taskBuilder.WriteString("写作纪律与粒度下限见同目录 `OVERLAY.md`。模板只规定章节结构,不得用 overlay 改写章节骨架。\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
taskBuilder.WriteString("## 交付契约与写作纪律\n")
|
||||||
|
taskBuilder.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`:\n", deliverableName))
|
||||||
|
taskBuilder.WriteString("1. 严格四段式展开(结论句 → 表格/列表 → 解读 → 支撑出处)。\n")
|
||||||
|
taskBuilder.WriteString("2. 各章节必须实质展开;禁止以篇幅借口或省略写法跳过应写细节(自检时勿复述禁用原词)。\n")
|
||||||
|
taskBuilder.WriteString("3. 关键事实必须标注出处,未知项列入待确认清单。\n")
|
||||||
|
if step.Role == "organizer" {
|
||||||
|
taskBuilder.WriteString("4. 从材料提取的客观字段,请在当前工作目录下输出 `fields.json`。\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(taskBuilder.String()), 0o644)
|
||||||
|
|
||||||
|
// 3. 写入 input_refs.json
|
||||||
|
inputRefs := map[string]any{
|
||||||
|
"step_id": step.ID,
|
||||||
|
"role": step.Role,
|
||||||
|
"needs": step.Needs,
|
||||||
|
"target_file": deliverableName,
|
||||||
|
}
|
||||||
|
_ = writeJSON(filepath.Join(subDir, "input_refs.json"), inputRefs)
|
||||||
|
|
||||||
|
// 4. 创建便捷软链接,使得当前目录下无论是读 template.md、context/ 还是 ../../ 均可访问
|
||||||
|
_ = os.Symlink(filepath.Join("..", "..", "template.md"), filepath.Join(subDir, "template.md"))
|
||||||
|
_ = os.Symlink(filepath.Join("..", "..", "context"), filepath.Join(subDir, "context"))
|
||||||
|
_ = os.Symlink(filepath.Join("..", "..", "blackboard.json"), filepath.Join(subDir, "blackboard.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user