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")
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package brief
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
)
|
||||
|
||||
func newReq(phase project.Phase) *project.Requirement {
|
||||
return &project.Requirement{
|
||||
ID: uuid.New(),
|
||||
Number: 7,
|
||||
Title: "导出报表",
|
||||
Description: "支持导出 Excel",
|
||||
Phase: phase,
|
||||
}
|
||||
}
|
||||
|
||||
func newIssue() *project.Issue {
|
||||
return &project.Issue{
|
||||
ID: uuid.New(),
|
||||
Number: 12,
|
||||
Title: "修复导出乱码",
|
||||
Description: "UTF-8 BOM 缺失",
|
||||
}
|
||||
}
|
||||
|
||||
func art(phase project.Phase, version int, content string) *project.Artifact {
|
||||
return &project.Artifact{
|
||||
ID: uuid.New(),
|
||||
RequirementID: uuid.New(),
|
||||
Phase: phase,
|
||||
Version: version,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeTaskBrief_FullBriefAllSectionsInPriorityOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
|
||||
|
||||
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: newReq(project.PhaseAuditing),
|
||||
Artifacts: []*project.Artifact{
|
||||
art(project.PhasePlanning, 1, "# 规划\n## 验收标准\n- 必须支持 Excel"),
|
||||
art(project.PhasePrototyping, 1, "# 原型内容"),
|
||||
art(project.PhaseAuditing, 1, "# 评审内容"),
|
||||
},
|
||||
UserPrompt: "请开始实现",
|
||||
Kind: KindACPSession,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Truncated)
|
||||
|
||||
// 角色(auditing 阶段用阶段角色)+ 交互协议 + 需求核心 + 验收标准 + 三个产物 + 用户指令。
|
||||
wantOrder := []string{
|
||||
"role", "interactive_protocol", "requirement_core", "acceptance_criteria",
|
||||
"artifact_auditing", "artifact_prototyping", "artifact_planning", "user_prompt",
|
||||
}
|
||||
assert.Equal(t, wantOrder, res.Sections)
|
||||
|
||||
// 验收标准必须出现在 auditing 产物之前(优先级)。
|
||||
idxAC := strings.Index(res.Text, "验收标准")
|
||||
idxAudit := strings.Index(res.Text, "评审内容")
|
||||
require.GreaterOrEqual(t, idxAC, 0)
|
||||
require.GreaterOrEqual(t, idxAudit, 0)
|
||||
assert.Less(t, idxAC, idxAudit)
|
||||
|
||||
// 用户指令永远在最后。
|
||||
assert.True(t, strings.HasSuffix(strings.TrimSpace(res.Text), "请开始实现"))
|
||||
// 角色提示与需求标题都在。
|
||||
assert.Contains(t, res.Text, PhaseRolePrompts[project.PhaseAuditing])
|
||||
assert.Contains(t, res.Text, "需求 #7:导出报表")
|
||||
}
|
||||
|
||||
func TestComposeTaskBrief_TightBudgetDropsLowPrioritySections(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewComposer(nil, Config{})
|
||||
|
||||
bigPlanning := strings.Repeat("规", 4000) // ~1000 tokens
|
||||
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: newReq(project.PhasePlanning),
|
||||
Artifacts: []*project.Artifact{
|
||||
art(project.PhasePlanning, 1, bigPlanning),
|
||||
art(project.PhaseAuditing, 1, strings.Repeat("评", 4000)),
|
||||
},
|
||||
UserPrompt: "做",
|
||||
Kind: KindACPSession,
|
||||
TokenBudget: 300, // 紧预算
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Truncated)
|
||||
// 高优先级(角色/需求核心)保留,用户指令永远保留。
|
||||
assert.Contains(t, res.Sections, "role")
|
||||
assert.Contains(t, res.Sections, "requirement_core")
|
||||
assert.Contains(t, res.Sections, "user_prompt")
|
||||
// 用户指令永不丢。
|
||||
assert.Contains(t, res.Text, "## 操作者指令\n做")
|
||||
}
|
||||
|
||||
func TestComposeTaskBrief_RequirementOnly_IssueOnly_Both(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
|
||||
|
||||
// requirement-only
|
||||
res1, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: newReq(project.PhasePlanning), Kind: KindChatRequirement,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res1.Sections, "requirement_core")
|
||||
assert.NotContains(t, res1.Sections, "issue_core")
|
||||
|
||||
// issue-only
|
||||
res2, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Issue: newIssue(), Kind: KindChatIssue,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res2.Sections, "issue_core")
|
||||
assert.NotContains(t, res2.Sections, "requirement_core")
|
||||
assert.Contains(t, res2.Text, "Issue #12:修复导出乱码")
|
||||
|
||||
// both
|
||||
res3, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: newReq(project.PhasePlanning), Issue: newIssue(), Kind: KindACPSession,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res3.Sections, "requirement_core")
|
||||
assert.Contains(t, res3.Sections, "issue_core")
|
||||
}
|
||||
|
||||
func TestComposeTaskBrief_MissingDataYieldsNoErrorAndOmitsSections(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
|
||||
|
||||
// 空描述 + 无产物。
|
||||
req := newReq(project.PhasePlanning)
|
||||
req.Description = ""
|
||||
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: req, Kind: KindChatRequirement,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, res.Text, "需求描述")
|
||||
assert.NotContains(t, res.Sections, "artifact_planning")
|
||||
assert.NotContains(t, res.Sections, "acceptance_criteria")
|
||||
|
||||
// 完全空输入(无 PM 上下文,仅 ACP 角色)。
|
||||
resEmpty, err := c.ComposeTaskBrief(context.Background(), BriefInput{Kind: KindACPSession})
|
||||
require.NoError(t, err)
|
||||
// ACP 场景仍给角色 + 交互协议,但无 PM section。
|
||||
assert.Contains(t, resEmpty.Sections, "role")
|
||||
assert.NotContains(t, resEmpty.Sections, "requirement_core")
|
||||
}
|
||||
|
||||
func TestComposeTaskBrief_AcceptanceCriteriaExtraction(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
|
||||
|
||||
planning := art(project.PhasePlanning, 3, `# 规划文档
|
||||
## 背景与目标
|
||||
做导出。
|
||||
## 验收标准
|
||||
- 能导出 Excel
|
||||
- 中文不乱码
|
||||
## 风险
|
||||
无`)
|
||||
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: newReq(project.PhasePlanning),
|
||||
Artifacts: []*project.Artifact{planning},
|
||||
Kind: KindChatRequirement,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.Sections, "acceptance_criteria")
|
||||
assert.Contains(t, res.Text, "能导出 Excel")
|
||||
assert.Contains(t, res.Text, "中文不乱码")
|
||||
// 验收标准段落不应吞掉后面的"风险"小节。
|
||||
acStart := strings.Index(res.Text, "## 验收标准")
|
||||
require.GreaterOrEqual(t, acStart, 0)
|
||||
}
|
||||
|
||||
func TestComposeTaskBrief_AcceptanceCriteriaFallbackOmitsWhenAbsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
|
||||
|
||||
planning := art(project.PhasePlanning, 1, "# 规划\n## 背景\n仅有背景,没有验收标准小节")
|
||||
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: newReq(project.PhasePlanning),
|
||||
Artifacts: []*project.Artifact{planning},
|
||||
Kind: KindChatRequirement,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, res.Sections, "acceptance_criteria")
|
||||
}
|
||||
|
||||
func TestComposeTaskBrief_UserPromptNeverTruncatedEvenWhenExceedsBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewComposer(nil, Config{})
|
||||
|
||||
huge := strings.Repeat("做", 5000) // 远超预算
|
||||
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: newReq(project.PhasePlanning),
|
||||
UserPrompt: huge,
|
||||
Kind: KindACPSession,
|
||||
TokenBudget: 50,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.Sections, "user_prompt")
|
||||
// 用户指令整段保留(不含截断标记)。
|
||||
assert.Contains(t, res.Text, huge)
|
||||
assert.NotContains(t, res.Text, "操作者指令\n"+huge+truncateMarker)
|
||||
}
|
||||
|
||||
func TestComposeTaskBrief_LatestArtifactPerPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
|
||||
|
||||
// 同阶段多版本,应取 version 最大的。
|
||||
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: newReq(project.PhasePrototyping),
|
||||
Artifacts: []*project.Artifact{
|
||||
art(project.PhasePrototyping, 1, "旧原型 v1"),
|
||||
art(project.PhasePrototyping, 3, "新原型 v3"),
|
||||
art(project.PhasePrototyping, 2, "中原型 v2"),
|
||||
},
|
||||
Kind: KindChatRequirement,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.Text, "新原型 v3")
|
||||
assert.NotContains(t, res.Text, "旧原型 v1")
|
||||
assert.Contains(t, res.Text, "prototyping 阶段产物 v3")
|
||||
}
|
||||
|
||||
// fakeOrienter 用于验证 ACP 场景纳入仓库定向 section。
|
||||
type fakeOrienter struct{ ori RepoOrientation }
|
||||
|
||||
func (f fakeOrienter) Orient(_ context.Context, _, _ string, _, _ int) RepoOrientation {
|
||||
return f.ori
|
||||
}
|
||||
|
||||
func TestComposeTaskBrief_RepoOrientationIncludedForACP(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewComposer(fakeOrienter{ori: RepoOrientation{
|
||||
Branch: "feat/x",
|
||||
RecentCommits: []string{"abc1234 init"},
|
||||
DirtyFiles: []string{"main.go"},
|
||||
}}, Config{DefaultTokenBudget: 100000})
|
||||
|
||||
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: newReq(project.PhasePlanning),
|
||||
RepoCwd: "/tmp/repo",
|
||||
Branch: "feat/x",
|
||||
Kind: KindACPSession,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.Sections, "repo_orientation")
|
||||
assert.Contains(t, res.Text, "当前分支:feat/x")
|
||||
assert.Contains(t, res.Text, "abc1234 init")
|
||||
assert.Contains(t, res.Text, "main.go")
|
||||
}
|
||||
|
||||
func TestComposeTaskBrief_NoOrientationWhenCwdEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewComposer(fakeOrienter{ori: RepoOrientation{Branch: "x"}}, Config{DefaultTokenBudget: 100000})
|
||||
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
|
||||
Requirement: newReq(project.PhasePlanning),
|
||||
Kind: KindACPSession, // RepoCwd 为空 → 跳过定向
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, res.Sections, "repo_orientation")
|
||||
}
|
||||
|
||||
func TestExtractSection_NumberedHeadingAndCaseInsensitive(t *testing.T) {
|
||||
t.Parallel()
|
||||
md := "## 6. Acceptance Criteria\n- foo\n## next\nbar"
|
||||
got := extractSection(md, acceptanceCriteriaHeadings)
|
||||
assert.Equal(t, "- foo", got)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package brief
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
||||
)
|
||||
|
||||
// RepoOrientation 是仓库的轻量定向信息:当前分支、最近若干提交摘要、脏文件路径。
|
||||
// 用于给 agent 一个"我现在在哪个分支、最近发生了什么、工作树是否干净"的速览。
|
||||
type RepoOrientation struct {
|
||||
Branch string
|
||||
RecentCommits []string
|
||||
DirtyFiles []string
|
||||
}
|
||||
|
||||
// IsEmpty 报告该定向信息是否完全为空(无分支、无提交、无脏文件)。
|
||||
func (o RepoOrientation) IsEmpty() bool {
|
||||
return o.Branch == "" && len(o.RecentCommits) == 0 && len(o.DirtyFiles) == 0
|
||||
}
|
||||
|
||||
// RepoOrienter 是 brief 对 git 的窄接口:给定目录返回有界的仓库定向信息。
|
||||
// 让 brief 可在没有真实仓库的情况下被单测(注入 fake orienter)。
|
||||
type RepoOrienter interface {
|
||||
Orient(ctx context.Context, dir, branch string, maxCommits, maxDirty int) RepoOrientation
|
||||
}
|
||||
|
||||
// gitClient 是 orienter 对 git.Runner 的最小子集。*git.DefaultRunner 直接满足。
|
||||
type gitClient interface {
|
||||
Log(ctx context.Context, dir string, n int) ([]git.Commit, error)
|
||||
Status(ctx context.Context, dir string) ([]git.FileStatus, error)
|
||||
}
|
||||
|
||||
// gitOrienter 是 RepoOrienter 的生产实现,基于 infra/git 的 Log + Status。
|
||||
// 设计为"尽力而为且永不致命":任何 git 调用失败只产生部分/空的定向信息,
|
||||
// 不返回 error,从而不会拖垮整份简报(git 失败不应阻塞 prompt 注入)。
|
||||
type gitOrienter struct{ g gitClient }
|
||||
|
||||
// NewRepoOrienter 用给定 git runner 构造 RepoOrienter。
|
||||
func NewRepoOrienter(g gitClient) RepoOrienter { return &gitOrienter{g: g} }
|
||||
|
||||
func (o *gitOrienter) Orient(ctx context.Context, dir, branch string, maxCommits, maxDirty int) RepoOrientation {
|
||||
out := RepoOrientation{Branch: branch}
|
||||
if dir == "" || o.g == nil {
|
||||
return out
|
||||
}
|
||||
if maxCommits > 0 {
|
||||
if commits, err := o.g.Log(ctx, dir, maxCommits); err == nil {
|
||||
for _, c := range commits {
|
||||
short := c.Hash
|
||||
if len(short) > 8 {
|
||||
short = short[:8]
|
||||
}
|
||||
out.RecentCommits = append(out.RecentCommits, short+" "+c.Subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxDirty > 0 {
|
||||
if files, err := o.g.Status(ctx, dir); err == nil {
|
||||
for i, f := range files {
|
||||
if i >= maxDirty {
|
||||
break
|
||||
}
|
||||
out.DirtyFiles = append(out.DirtyFiles, f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package brief
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
||||
)
|
||||
|
||||
func gitAvailable(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not found in PATH")
|
||||
}
|
||||
}
|
||||
|
||||
func mustGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
c := exec.Command("git", args...)
|
||||
c.Dir = dir
|
||||
out, err := c.CombinedOutput()
|
||||
require.NoError(t, err, "git %v: %s", args, string(out))
|
||||
}
|
||||
|
||||
// makeWorkRepo 在临时目录里建一个有 N 次提交、可选脏文件的工作仓库,返回路径。
|
||||
func makeWorkRepo(t *testing.T, commits int, dirty bool) string {
|
||||
t.Helper()
|
||||
gitAvailable(t)
|
||||
dir := t.TempDir()
|
||||
mustGit(t, dir, "init", "-b", "main")
|
||||
mustGit(t, dir, "config", "user.email", "test@example.com")
|
||||
mustGit(t, dir, "config", "user.name", "Test")
|
||||
for i := 0; i < commits; i++ {
|
||||
name := filepath.Join(dir, "f"+string(rune('a'+i))+".txt")
|
||||
require.NoError(t, os.WriteFile(name, []byte("v"), 0o644))
|
||||
mustGit(t, dir, "add", ".")
|
||||
mustGit(t, dir, "commit", "-m", "commit "+string(rune('a'+i)))
|
||||
}
|
||||
if dirty {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "dirty.txt"), []byte("x"), 0o644))
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestRepoOrienter_RealRepo(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := makeWorkRepo(t, 3, true)
|
||||
o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()}))
|
||||
|
||||
got := o.Orient(context.Background(), dir, "main", 10, 10)
|
||||
assert.Equal(t, "main", got.Branch)
|
||||
assert.Len(t, got.RecentCommits, 3)
|
||||
// 脏文件含未跟踪的 dirty.txt。
|
||||
require.NotEmpty(t, got.DirtyFiles)
|
||||
assert.Contains(t, got.DirtyFiles, "dirty.txt")
|
||||
}
|
||||
|
||||
func TestRepoOrienter_BoundsCommits(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := makeWorkRepo(t, 5, false)
|
||||
o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()}))
|
||||
|
||||
got := o.Orient(context.Background(), dir, "main", 2, 10)
|
||||
assert.LessOrEqual(t, len(got.RecentCommits), 2)
|
||||
}
|
||||
|
||||
func TestRepoOrienter_NonexistentDirReturnsPartialNoFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gitAvailable(t)
|
||||
o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()}))
|
||||
|
||||
// 不存在的目录:git 调用失败,但 orienter 不 panic、不返回错误,分支仍带入。
|
||||
got := o.Orient(context.Background(), filepath.Join(t.TempDir(), "nope"), "wip", 10, 10)
|
||||
assert.Equal(t, "wip", got.Branch)
|
||||
assert.Empty(t, got.RecentCommits)
|
||||
assert.Empty(t, got.DirtyFiles)
|
||||
}
|
||||
|
||||
func TestRepoOrienter_EmptyDirSkips(t *testing.T) {
|
||||
t.Parallel()
|
||||
o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()}))
|
||||
got := o.Orient(context.Background(), "", "main", 10, 10)
|
||||
assert.Equal(t, "main", got.Branch)
|
||||
assert.Empty(t, got.RecentCommits)
|
||||
assert.Empty(t, got.DirtyFiles)
|
||||
}
|
||||
|
||||
func TestRepoOrienter_NilGitClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
o := NewRepoOrienter(nil)
|
||||
got := o.Orient(context.Background(), "/tmp/x", "main", 10, 10)
|
||||
assert.Equal(t, "main", got.Branch)
|
||||
assert.Empty(t, got.RecentCommits)
|
||||
}
|
||||
|
||||
func TestRepoOrientation_IsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.True(t, RepoOrientation{}.IsEmpty())
|
||||
assert.False(t, RepoOrientation{Branch: "x"}.IsEmpty())
|
||||
assert.False(t, RepoOrientation{RecentCommits: []string{"a"}}.IsEmpty())
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package brief 是服务端"上下文组装器":把 (project, requirement|issue, artifacts,
|
||||
// repo orientation, 用户自由文本) 组装成一份 token 受限的结构化任务简报,供 ACP
|
||||
// session 初始 prompt 与 chat composeHistory 的 FK 上下文注入复用。
|
||||
//
|
||||
// 依赖纪律:brief 只依赖 project 领域类型 + infra/git + infra/llm,绝不导入
|
||||
// acp/chat(避免 import cycle)。acp/chat 通过 app.go 中的窄适配器反向调用 brief。
|
||||
package brief
|
||||
|
||||
import "github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
|
||||
// PhaseRolePrompts 是三个 AI 对话阶段的角色模板,从前端
|
||||
// web/src/constants/requirementPhasePrompts.ts 的 PHASE_ROLE_PROMPTS 逐字迁移。
|
||||
// 服务端拥有这套提示词后,ACP session 与 chat 都能获得一致的阶段角色注入。
|
||||
var PhaseRolePrompts = map[project.Phase]string{
|
||||
project.PhasePlanning: `你是一名资深产品规划助手。你的任务是与用户深入讨论需求,澄清目标、范围、用户场景与约束,最终形成一份结构化的规划文档(Markdown)。
|
||||
讨论时主动提问补全缺失信息;输出规划文档时包含:背景与目标、范围(含不做什么)、用户场景、功能拆解、风险与依赖、验收标准。`,
|
||||
project.PhasePrototyping: `你是一名资深原型设计助手。基于已确认的规划,与用户讨论并产出原型设计文档(Markdown)。
|
||||
内容应包含:页面/界面结构、交互流程、状态与边界情况、数据展示与输入约束;可用文字线框、列表与表格描述布局。`,
|
||||
project.PhaseAuditing: `你是一名严格的方案评审助手。基于规划与原型,对方案进行评审并产出评审报告(Markdown)。
|
||||
评审维度:完整性(是否覆盖规划目标)、一致性(原型与规划是否矛盾)、可行性(技术与排期风险)、边界情况遗漏、验收标准可测性。明确给出问题清单与修改建议,并给出评审结论(通过 / 有条件通过 / 不通过)。`,
|
||||
}
|
||||
|
||||
// InteractiveQuestionProtocol 是结构化澄清问题协议,从前端
|
||||
// web/src/constants/requirementPhasePrompts.ts 的 INTERACTIVE_QUESTION_PROMPT 逐字迁移。
|
||||
const InteractiveQuestionProtocol = `## 交互式澄清问题协议
|
||||
当信息不足且用户直接输入大段文字的成本较高时,优先提出一个结构化澄清问题,方便用户点选。
|
||||
每次最多输出 1 个结构化问题;问题必须有 2-5 个选项;只有选项允许多选时 mode 才使用 multiple。
|
||||
结构化问题必须放在 acw-question 代码块中,代码块内只能是合法 JSON,不能输出 HTML。
|
||||
字段格式如下:
|
||||
` + "```acw-question" + `
|
||||
{
|
||||
"version": 1,
|
||||
"id": "stable_question_id",
|
||||
"title": "需要用户确认的问题",
|
||||
"description": "选择这个问题的原因,可省略",
|
||||
"mode": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "stable_option_id",
|
||||
"label": "给用户看的短选项",
|
||||
"summary": "一句话解释,可省略",
|
||||
"details": "更完整的影响说明,可省略"
|
||||
}
|
||||
],
|
||||
"allowCustom": true
|
||||
}
|
||||
` + "```" + `
|
||||
用户回答结构化问题后,继续基于回答推进本阶段讨论或产物输出。`
|
||||
|
||||
// ACPRolePrompt 是 ACP session(执行型 agent,非阶段对话)默认的角色说明。
|
||||
// 与 PhaseRolePrompts(规划/原型/评审三阶段讨论助手)不同,ACP session 通常是
|
||||
// 直接对代码仓库动手的执行型 agent,因此给出一段中性的工程执行角色。
|
||||
const ACPRolePrompt = `你是一名在真实代码仓库中工作的资深工程实现助手。请基于下面提供的需求/Issue 背景、阶段产物与仓库现状,谨慎地推进实现:先理解上下文,必要时澄清,再动手修改;保持改动最小且可验证。`
|
||||
Reference in New Issue
Block a user