This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+432
View File
@@ -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")
}
+282
View File
@@ -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)
}
+69
View File
@@ -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
}
+107
View File
@@ -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())
}
+53
View File
@@ -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 背景、阶段产物与仓库现状,谨慎地推进实现:先理解上下文,必要时澄清,再动手修改;保持改动最小且可验证。`