Files
agentic-coding-workflow/internal/brief/composer.go
T
2026-06-22 08:55:57 +08:00

433 lines
13 KiB
Go

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")
}