package brief import ( "context" "fmt" "sort" "strings" "github.com/yan1h/agent-coding-workflow/internal/infra/llm" "github.com/yan1h/agent-coding-workflow/internal/project" ) // BriefKind 标识简报的使用场景,影响默认角色提示与是否做仓库定向。 type BriefKind string const ( // KindACPSession 用于 ACP 执行型 session 的初始 prompt(含仓库定向)。 KindACPSession BriefKind = "acp_session" // KindChatRequirement 用于挂载到 requirement 的 chat 会话 FK 注入。 KindChatRequirement BriefKind = "chat_requirement" // KindChatIssue 用于挂载到 issue 的 chat 会话 FK 注入。 KindChatIssue BriefKind = "chat_issue" ) // 默认 token 预算与有界参数。可被 Config 覆盖。 const ( DefaultTokenBudget = 6000 defaultMaxCommits = 10 defaultMaxDirty = 20 // minSectionTokens 是一个 section 至少要能容纳的 token 数;不足以放下哪怕 // 这么多时直接跳过该 section(避免输出只剩一个截断标记的无意义碎片)。 minSectionTokens = 16 truncateMarker = "\n\n……(已截断)" ) // truncatedSectionTokens 是 truncateMarker 的 token 预算占用估算,预留给截断标记。 var truncatedSectionTokens = llm.EstimateTokens(truncateMarker) // BriefInput 是组装一份简报所需的全部输入。所有数据由调用方(app.go 适配器) // 预先解析好传入,brief 不主动访问仓储,从而不产生跨模块依赖。 type BriefInput struct { Project *project.Project Requirement *project.Requirement // nil 表示 issue-only 或自由对话 Issue *project.Issue // nil 表示 requirement-only Artifacts []*project.Artifact // 各阶段产物(可含多版本,composer 自行取每阶段最新) RepoCwd string // session CwdPath / workspace main path,用于 git 定向 Branch string // 当前分支(session.Branch),填入 RepoOrientation.Branch UserPrompt string // 操作者的自由文本初始 prompt,永远追加在最后 TokenBudget int // 硬上限;<=0 时用 Config.DefaultTokenBudget Kind BriefKind } // BriefResult 是组装结果。 type BriefResult struct { Text string // 组装好的、token 受限的简报 Tokens int // llm.EstimateTokens(Text) Truncated bool // 是否有任意 section 被裁剪以适配预算 Sections []string // 实际纳入的 section 名(用于 audit/debug) } // Composer 是 brief 包的唯一入口。 type Composer interface { ComposeTaskBrief(ctx context.Context, in BriefInput) (BriefResult, error) } // Config 控制 Composer 的默认预算与定向参数。 type Config struct { DefaultTokenBudget int MaxCommits int MaxDirtyFiles int } func (c Config) withDefaults() Config { if c.DefaultTokenBudget <= 0 { c.DefaultTokenBudget = DefaultTokenBudget } if c.MaxCommits <= 0 { c.MaxCommits = defaultMaxCommits } if c.MaxDirtyFiles <= 0 { c.MaxDirtyFiles = defaultMaxDirty } return c } type composer struct { orienter RepoOrienter cfg Config } // NewComposer 构造 Composer。orienter 可为 nil(chat 场景通常不做 git 定向以避免 // 请求路径阻塞);nil 时跳过仓库定向 section。 func NewComposer(orienter RepoOrienter, cfg Config) Composer { return &composer{orienter: orienter, cfg: cfg.withDefaults()} } // section 是简报的一段,按 priority 升序加入(数字越小优先级越高、越先放入预算)。 type section struct { name string body string truncatable bool // true 时预算不足可截断;false 时只能整段纳入或整段丢弃 } // ComposeTaskBrief 按优先级顺序组装 section 并做 section 粒度的 token 预算控制。 // // 优先级(高→低): // // role/instructions > requirement/issue core > acceptance criteria > // latest auditing artifact > prototyping > planning > repo orientation > user prompt // // user prompt 永远最后追加且不被截断(即使它单独就超预算也保留——它是操作者的 // 真实意图,宁可超 budget 也不能丢)。 func (c *composer) ComposeTaskBrief(ctx context.Context, in BriefInput) (BriefResult, error) { budget := in.TokenBudget if budget <= 0 { budget = c.cfg.DefaultTokenBudget } secs := c.buildSections(ctx, in) var ( included []string bodies []string used int trunc bool ) for _, s := range secs { if s.body == "" { continue } // user prompt 特殊:永远纳入、永不截断。 if !s.truncatable { bodies = append(bodies, s.body) included = append(included, s.name) used += llm.EstimateTokens(s.body) continue } remaining := budget - used if remaining < minSectionTokens { trunc = true continue } cost := llm.EstimateTokens(s.body) if cost <= remaining { bodies = append(bodies, s.body) included = append(included, s.name) used += cost continue } // 预算不足:截断当前 section 以塞进剩余预算。 cut := truncateToTokens(s.body, remaining-truncatedSectionTokens) if cut == "" { trunc = true continue } body := cut + truncateMarker bodies = append(bodies, body) included = append(included, s.name) used += llm.EstimateTokens(body) trunc = true } text := strings.Join(bodies, "\n\n") return BriefResult{ Text: text, Tokens: llm.EstimateTokens(text), Truncated: trunc, Sections: included, }, nil } // buildSections 把 BriefInput 解析成有序的 section 列表(已按优先级排好)。 func (c *composer) buildSections(ctx context.Context, in BriefInput) []section { var secs []section // 1. 角色 / 指令 if role := c.rolePrompt(in); role != "" { secs = append(secs, section{name: "role", body: role, truncatable: false}) } if in.Kind == KindACPSession || in.Requirement != nil || in.Issue != nil { secs = append(secs, section{name: "interactive_protocol", body: InteractiveQuestionProtocol, truncatable: false}) } // 2. requirement / issue 核心 if core := requirementCore(in.Requirement); core != "" { secs = append(secs, section{name: "requirement_core", body: core, truncatable: true}) } if core := issueCore(in.Issue); core != "" { secs = append(secs, section{name: "issue_core", body: core, truncatable: true}) } latest := latestArtifactsByPhase(in.Artifacts) // 3. 验收标准:优先取 requirement 的最新 planning 产物中的"验收标准"段落。 if ac := acceptanceCriteria(in.Requirement, latest); ac != "" { secs = append(secs, section{name: "acceptance_criteria", body: ac, truncatable: true}) } // 4-6. 阶段产物(auditing > prototyping > planning) if a := latest[project.PhaseAuditing]; a != nil { secs = append(secs, section{name: "artifact_auditing", body: artifactBody(a), truncatable: true}) } if a := latest[project.PhasePrototyping]; a != nil { secs = append(secs, section{name: "artifact_prototyping", body: artifactBody(a), truncatable: true}) } if a := latest[project.PhasePlanning]; a != nil { secs = append(secs, section{name: "artifact_planning", body: artifactBody(a), truncatable: true}) } // 7. 仓库定向(仅 ACP 场景且有 orienter + cwd) if c.orienter != nil && in.RepoCwd != "" { ori := c.orienter.Orient(ctx, in.RepoCwd, in.Branch, c.cfg.MaxCommits, c.cfg.MaxDirtyFiles) if body := orientationBody(ori); body != "" { secs = append(secs, section{name: "repo_orientation", body: body, truncatable: true}) } } // 8. 用户自由文本(最后,永不截断) if up := strings.TrimSpace(in.UserPrompt); up != "" { secs = append(secs, section{name: "user_prompt", body: "## 操作者指令\n" + up, truncatable: false}) } return secs } func (c *composer) rolePrompt(in BriefInput) string { // requirement 有阶段时优先用阶段角色(与原前端 PHASE_ROLE_PROMPTS 行为一致)。 if in.Requirement != nil { if rp, ok := PhaseRolePrompts[in.Requirement.Phase]; ok && rp != "" { return rp } } if in.Kind == KindACPSession { return ACPRolePrompt } return "" } func requirementCore(req *project.Requirement) string { if req == nil { return "" } var b strings.Builder fmt.Fprintf(&b, "## 当前需求\n需求 #%d:%s", req.Number, req.Title) if d := strings.TrimSpace(req.Description); d != "" { fmt.Fprintf(&b, "\n### 需求描述\n%s", d) } return b.String() } func issueCore(iss *project.Issue) string { if iss == nil { return "" } var b strings.Builder fmt.Fprintf(&b, "## 当前 Issue\nIssue #%d:%s", iss.Number, iss.Title) if d := strings.TrimSpace(iss.Description); d != "" { fmt.Fprintf(&b, "\n### Issue 描述\n%s", d) } return b.String() } func artifactBody(a *project.Artifact) string { if a == nil { return "" } content := strings.TrimSpace(a.Content) if content == "" { return "" } return fmt.Sprintf("## %s 阶段产物 v%d\n%s", a.Phase, a.Version, content) } func orientationBody(o RepoOrientation) string { if o.IsEmpty() { return "" } var b strings.Builder b.WriteString("## 仓库现状") if o.Branch != "" { fmt.Fprintf(&b, "\n当前分支:%s", o.Branch) } if len(o.RecentCommits) > 0 { b.WriteString("\n最近提交:") for _, c := range o.RecentCommits { fmt.Fprintf(&b, "\n- %s", c) } } if len(o.DirtyFiles) > 0 { b.WriteString("\n未提交改动:") for _, f := range o.DirtyFiles { fmt.Fprintf(&b, "\n- %s", f) } } return b.String() } // latestArtifactsByPhase 按 (phase) 取 version 最大的产物。输入可包含同阶段多版本。 func latestArtifactsByPhase(arts []*project.Artifact) map[project.Phase]*project.Artifact { out := make(map[project.Phase]*project.Artifact, len(arts)) for _, a := range arts { if a == nil { continue } cur, ok := out[a.Phase] if !ok || a.Version > cur.Version { out[a.Phase] = a } } return out } // acceptanceCriteriaHeadings 是从 planning 产物中提取"验收标准"段落时识别的标题关键字。 var acceptanceCriteriaHeadings = []string{"验收标准", "acceptance criteria", "acceptance"} // acceptanceCriteria 解析验收标准。当前 requirement 无一级字段,回退为从最新 // planning 产物的 Markdown 中提取"验收标准 / Acceptance Criteria"小节。 // 保守策略:找不到明确小节时返回空串(宁可省略也不误纳入)。 func acceptanceCriteria(_ *project.Requirement, latest map[project.Phase]*project.Artifact) string { planning := latest[project.PhasePlanning] if planning == nil { return "" } body := extractSection(planning.Content, acceptanceCriteriaHeadings) if body == "" { return "" } return "## 验收标准\n" + body } // extractSection 从 Markdown 文本中提取标题匹配 headings 之一的小节正文(到下一个 // 同级或更高级标题前为止)。匹配大小写不敏感、忽略标题中的标点与空白。 func extractSection(md string, headings []string) string { lines := strings.Split(md, "\n") startIdx := -1 startLevel := 0 for i, line := range lines { level, title, ok := parseHeading(line) if !ok { continue } if matchesHeading(title, headings) { startIdx = i startLevel = level break } } if startIdx < 0 { return "" } var bodyLines []string for i := startIdx + 1; i < len(lines); i++ { level, _, ok := parseHeading(lines[i]) if ok && level <= startLevel { break } bodyLines = append(bodyLines, lines[i]) } return strings.TrimSpace(strings.Join(bodyLines, "\n")) } // parseHeading 解析一行是否为 ATX Markdown 标题(# / ## / ...)。返回级别与标题文本。 func parseHeading(line string) (level int, title string, ok bool) { trimmed := strings.TrimLeft(line, " ") n := 0 for n < len(trimmed) && trimmed[n] == '#' { n++ } if n == 0 || n > 6 { return 0, "", false } if n < len(trimmed) && trimmed[n] != ' ' { return 0, "", false } return n, strings.TrimSpace(trimmed[n:]), true } func matchesHeading(title string, headings []string) bool { norm := normalizeHeading(title) for _, h := range headings { if norm == normalizeHeading(h) { return true } } return false } // normalizeHeading 归一化标题:小写、去掉首尾标点与空白、去掉常见编号前缀。 func normalizeHeading(s string) string { s = strings.ToLower(strings.TrimSpace(s)) s = strings.Trim(s, "  ::.。、") // 去掉形如 "6. " / "6、" 的编号前缀 for i := 0; i < len(s); i++ { if s[i] >= '0' && s[i] <= '9' { continue } if s[i] == '.' || s[i] == ' ' || s[i] == '\t' { s = strings.TrimLeft(s[i:], ". \t") } break } return strings.TrimSpace(s) } // truncateToTokens 把 s 裁剪到不超过 maxTokens 的最长前缀(按 rune 与 EstimateTokens // 的 rune/4 估算反推,再二分微调,保证不超预算)。maxTokens<=0 返回空串。 func truncateToTokens(s string, maxTokens int) string { if maxTokens <= 0 { return "" } runes := []rune(s) if llm.EstimateTokens(s) <= maxTokens { return s } // EstimateTokens(x) = ceil(len(runes)/4),故 maxRunes ≈ maxTokens*4。 hi := maxTokens * 4 if hi > len(runes) { hi = len(runes) } // 用 sort.Search 找最大的 n(runes 前缀)使 EstimateTokens 仍 <= maxTokens。 n := sort.Search(hi+1, func(k int) bool { return llm.EstimateTokens(string(runes[:k])) > maxTokens }) if n > 0 { n-- } return strings.TrimRight(string(runes[:n]), " \n\t") }