You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user