magic-agent 1.0.49 → 1.0.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2168 -143
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-client.js +0 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
package requirements
|
|
2
|
+
|
|
3
|
+
// consolidate.go - 归纳主流程:记忆 → 功能点文档。
|
|
4
|
+
//
|
|
5
|
+
// 口径继承业务项目(magic-test desktop/requirements.cjs)里**实测出来**的那几条:
|
|
6
|
+
//
|
|
7
|
+
// · 模型只给增量(desc / points / conclusions / sources 编号),骨架与日期由代码写;
|
|
8
|
+
// · 已有文档**只喂「模块 + 名称 + 已有需求点/结论」**,不喂整篇(省 token,去重更准);
|
|
9
|
+
// · 归属以「已有文档的模块」为准,模型这轮给的 module 只对**新文档**生效
|
|
10
|
+
// (否则同一篇在两轮归纳里被搬来搬去,用户刚记住的位置就变了);
|
|
11
|
+
// · 全量重放要**分片**:一次让模型输出所有功能点会被输出上限截断(实测 22 篇断在半句);
|
|
12
|
+
// · 「来源对话」由代码按记忆编号回查原文 —— 不让模型摘抄(实测它只摘当轮那一条)。
|
|
13
|
+
//
|
|
14
|
+
// 与业务项目不同的两处:
|
|
15
|
+
//
|
|
16
|
+
// ① 归纳用哪个引擎:那边固定 llm 引擎;这里可用 Options.Engine 指定,默认仍是 llm
|
|
17
|
+
// (纯文本、无工具:归纳不需要读文件,给它工具反而会把工具痕迹混进 JSON)。
|
|
18
|
+
// ② 「最终版」由代码从「需求描述 + 已定结论」收敛生成,不额外调一次 LLM。
|
|
19
|
+
// 业务项目的最终版是「手动点一下才生成」的定稿;这里是全自动链路,没有那个点击时机,
|
|
20
|
+
// 留一个永远「(待生成)」的段等于交付半个产品。代价是它会随结论增长而变化 ——
|
|
21
|
+
// 但它是**派生**内容,不是用户编辑过的正文,变化符合预期。
|
|
22
|
+
|
|
23
|
+
import (
|
|
24
|
+
"context"
|
|
25
|
+
"encoding/json"
|
|
26
|
+
"fmt"
|
|
27
|
+
"os"
|
|
28
|
+
"path/filepath"
|
|
29
|
+
"strconv"
|
|
30
|
+
"strings"
|
|
31
|
+
"time"
|
|
32
|
+
|
|
33
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// DefaultTimeout 单次归纳 LLM 调用的默认超时。
|
|
37
|
+
//
|
|
38
|
+
// 240s 与业务项目同口径:80 条记忆 + 二十来篇已有文档的 prompt,实测 40~90s 出结果,
|
|
39
|
+
// 推理模型翻倍。给到 4 分钟既能容忍慢模型,也不会让「自动归纳」把进程吊太久。
|
|
40
|
+
const DefaultTimeout = 240 * time.Second
|
|
41
|
+
|
|
42
|
+
// lockStale 归纳锁超过这个时长视为陈旧(进程被 kill 时会留下锁文件)。
|
|
43
|
+
const lockStale = 15 * time.Minute
|
|
44
|
+
|
|
45
|
+
// outSchema 归纳输出契约(只给增量,不给整篇文档)。
|
|
46
|
+
//
|
|
47
|
+
// 它同时承担两件事:llm 引擎的结构化输出后处理(顶层 Required 校验)、
|
|
48
|
+
// 以及渲染进 prompt 的形状说明(见 buildPrompt 末尾)。
|
|
49
|
+
var outSchema = &agent.JSONSchema{
|
|
50
|
+
Type: "object",
|
|
51
|
+
Properties: map[string]map[string]any{
|
|
52
|
+
"features": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": map[string]any{
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": map[string]any{
|
|
57
|
+
"module": map[string]any{"type": "string"},
|
|
58
|
+
"name": map[string]any{"type": "string"},
|
|
59
|
+
"desc": map[string]any{"type": "string"},
|
|
60
|
+
"points": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
61
|
+
"conclusions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
62
|
+
"sources": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}},
|
|
63
|
+
},
|
|
64
|
+
"required": []string{"module", "name", "desc", "points", "conclusions", "sources"},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
Required: []string{"features"},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// featureOut 模型给出的一条功能点增量。
|
|
72
|
+
type featureOut struct {
|
|
73
|
+
Module string `json:"module"`
|
|
74
|
+
Name string `json:"name"`
|
|
75
|
+
Desc string `json:"desc"`
|
|
76
|
+
Points []string
|
|
77
|
+
Conclusions []string
|
|
78
|
+
Sources []int
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// UnmarshalJSON 容忍 sources 里混进字符串编号(模型偶尔给 "#12")。
|
|
82
|
+
func (f *featureOut) UnmarshalJSON(b []byte) error {
|
|
83
|
+
var raw struct {
|
|
84
|
+
Module string `json:"module"`
|
|
85
|
+
Name string `json:"name"`
|
|
86
|
+
Desc string `json:"desc"`
|
|
87
|
+
Points []string `json:"points"`
|
|
88
|
+
Conclusions []string `json:"conclusions"`
|
|
89
|
+
Sources []json.RawMessage `json:"sources"`
|
|
90
|
+
}
|
|
91
|
+
if err := json.Unmarshal(b, &raw); err != nil {
|
|
92
|
+
return err
|
|
93
|
+
}
|
|
94
|
+
f.Module, f.Name, f.Desc = raw.Module, raw.Name, raw.Desc
|
|
95
|
+
f.Points, f.Conclusions = raw.Points, raw.Conclusions
|
|
96
|
+
for _, s := range raw.Sources {
|
|
97
|
+
var n int
|
|
98
|
+
if json.Unmarshal(s, &n) == nil {
|
|
99
|
+
f.Sources = append(f.Sources, n)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
var str string
|
|
103
|
+
if json.Unmarshal(s, &str) == nil {
|
|
104
|
+
if v, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(str), "#"))); err == nil {
|
|
105
|
+
f.Sources = append(f.Sources, v)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return nil
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Consolidate 跑一轮归纳。
|
|
113
|
+
//
|
|
114
|
+
// where 可以是工作区 id(ws-xxxx),也可以是目录路径(含 ~);空 = 当前目录。
|
|
115
|
+
// 一轮的语义:先把该目录下所有会话的新 user 事件补进记忆流(ingest),
|
|
116
|
+
// 再把游标之后、上限之内的记忆喂给模型,把返回的增量合并进功能点文档。
|
|
117
|
+
func Consolidate(ctx context.Context, where string, opts Options) (Result, error) {
|
|
118
|
+
dir := strings.TrimSpace(where)
|
|
119
|
+
if dir == "" {
|
|
120
|
+
if cwd, err := os.Getwd(); err == nil {
|
|
121
|
+
dir = cwd
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
ws, ok := resolveWorkspace(dir)
|
|
125
|
+
if !ok {
|
|
126
|
+
return Result{Dir: dir, Error: "工作区不存在: " + dir}, fmt.Errorf("requirements: workspace not found: %s", dir)
|
|
127
|
+
}
|
|
128
|
+
if Root() == "" {
|
|
129
|
+
return Result{Workspace: ws.ID, Dir: ws.Dir, Error: "无法确定需求存储目录"}, fmt.Errorf("requirements: no storage root")
|
|
130
|
+
}
|
|
131
|
+
res := Result{Workspace: ws.ID, Dir: ws.Dir}
|
|
132
|
+
|
|
133
|
+
// 锁:同一工作区同时只允许一轮归纳(客户端防抖 + 手动「立即整理」可能撞上)。
|
|
134
|
+
release, busy := acquireLock(ws.ID)
|
|
135
|
+
if busy {
|
|
136
|
+
res.Error = "另一个进程正在归纳,跳过"
|
|
137
|
+
return res, errBusy
|
|
138
|
+
}
|
|
139
|
+
defer release()
|
|
140
|
+
|
|
141
|
+
if ws.Dir != "" {
|
|
142
|
+
registerWorkspace(ws.Dir)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
st := readState(ws.ID)
|
|
146
|
+
// 摄入失败不阻断归纳:已有记忆照样能归纳,无非是这一轮少吃几条。
|
|
147
|
+
_, _ = ingest(ws.ID, ws.Dir, &st)
|
|
148
|
+
_ = writeState(ws.ID, st) // 摄入游标先落盘:即使下面 LLM 失败,也不重复摄入
|
|
149
|
+
|
|
150
|
+
mem := readMemory(ws.ID)
|
|
151
|
+
|
|
152
|
+
/* 首次「整理」:先按空间目录反推一版需求基线,再把旧会话记为已消费。
|
|
153
|
+
判断依据是 state.BootstrapAt(不是文档数 / 不是 LastConsolidatedAt):
|
|
154
|
+
自动归纳可能已经产出过几篇文档,但「有没有按目录初始化过」是另一件事。
|
|
155
|
+
opts.Auto 的自动路径不初始化 —— 它每轮都可能跑,不能悄悄花几分钟读代码。 */
|
|
156
|
+
if st.BootstrapAt == 0 && !opts.Auto {
|
|
157
|
+
boot, berr := bootstrapWorkspace(ctx, ws, opts)
|
|
158
|
+
if berr != nil {
|
|
159
|
+
res.Error = truncRunes(berr.Error(), 240)
|
|
160
|
+
_ = writeState(ws.ID, withError(st, res.Error))
|
|
161
|
+
return res, berr
|
|
162
|
+
}
|
|
163
|
+
res.Scanned = boot.Scanned
|
|
164
|
+
res.BootstrapEngine = boot.Engine
|
|
165
|
+
now := time.Now().UnixMilli()
|
|
166
|
+
st.BootstrapAt = now
|
|
167
|
+
st.LastConsolidatedAt = now
|
|
168
|
+
st.LastError = ""
|
|
169
|
+
/* 这一轮确实做了「首次初始化」——目录为空也算(标记已落盘,用户点过就该有反馈)。
|
|
170
|
+
Calls 只统计真的调了模型的轮次(空目录不会调)。 */
|
|
171
|
+
res.Bootstrap = true
|
|
172
|
+
if boot.Called {
|
|
173
|
+
res.Calls++ // 反推是一次带工具的 agent 调用(与下面的归纳共用 calls 口径)
|
|
174
|
+
}
|
|
175
|
+
if !boot.Empty {
|
|
176
|
+
res.Updated += boot.Updated
|
|
177
|
+
res.Created += boot.Created
|
|
178
|
+
res.Points += boot.Points
|
|
179
|
+
res.Conclusions += boot.Conclusions
|
|
180
|
+
/* 目录基线已经覆盖了「此刻之前的历史」:旧会话不再逐条重放。
|
|
181
|
+
这正是用户要的「没有初始化过 → 按空间目录重新生成;之后只处理新会话」。
|
|
182
|
+
--requirement-all 是显式回填,保留重放语义(游标不动)。 */
|
|
183
|
+
if !opts.All {
|
|
184
|
+
st.Consumed = len(mem)
|
|
185
|
+
if err := writeState(ws.ID, st); err != nil {
|
|
186
|
+
res.Error = "写状态失败: " + err.Error()
|
|
187
|
+
return res, err
|
|
188
|
+
}
|
|
189
|
+
res.OK = true
|
|
190
|
+
res.Pending = 0
|
|
191
|
+
return res, nil
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/* 目录为空(或显式全量重放):初始化标记照落,继续走会话增量。
|
|
195
|
+
空目录不是错误 —— 一个只有对话、没有代码的工作区也应该能靠聊天长需求。 */
|
|
196
|
+
if err := writeState(ws.ID, st); err != nil {
|
|
197
|
+
res.Error = "写状态失败: " + err.Error()
|
|
198
|
+
return res, err
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
cursor := st.Consumed
|
|
203
|
+
if cursor < 0 {
|
|
204
|
+
cursor = 0
|
|
205
|
+
}
|
|
206
|
+
if cursor > len(mem) {
|
|
207
|
+
cursor = len(mem) // 记忆文件被外部截断 → 游标跟着收敛,不越界
|
|
208
|
+
}
|
|
209
|
+
entries := mem[cursor:]
|
|
210
|
+
if opts.All {
|
|
211
|
+
entries = mem
|
|
212
|
+
}
|
|
213
|
+
if len(entries) == 0 && !opts.Force {
|
|
214
|
+
res.OK = true
|
|
215
|
+
res.Noop = true
|
|
216
|
+
res.Pending = 0
|
|
217
|
+
return res, nil
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
use := entries
|
|
221
|
+
if opts.All {
|
|
222
|
+
if len(use) > MaxReplayEntries {
|
|
223
|
+
use = use[len(use)-MaxReplayEntries:]
|
|
224
|
+
}
|
|
225
|
+
} else if len(use) > MaxEntriesPerRun {
|
|
226
|
+
use = use[:MaxEntriesPerRun]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
engineName := strings.TrimSpace(opts.Engine)
|
|
230
|
+
if engineName == "" {
|
|
231
|
+
engineName = "llm"
|
|
232
|
+
}
|
|
233
|
+
timeout := opts.Timeout
|
|
234
|
+
if timeout <= 0 {
|
|
235
|
+
timeout = DefaultTimeout
|
|
236
|
+
}
|
|
237
|
+
call, callErr := newCaller(ctx, engineName, opts.Model, timeout)
|
|
238
|
+
if callErr != nil {
|
|
239
|
+
res.Error = callErr.Error()
|
|
240
|
+
_ = writeState(ws.ID, withError(st, res.Error))
|
|
241
|
+
return res, callErr
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
chunks := chunkEntries(use, opts.All)
|
|
245
|
+
merger := newDocMerger(ws.ID)
|
|
246
|
+
byN := make(map[int]Entry, len(mem))
|
|
247
|
+
for _, e := range mem {
|
|
248
|
+
byN[e.N] = e
|
|
249
|
+
}
|
|
250
|
+
wsName := ws.Name
|
|
251
|
+
if wsName == "" {
|
|
252
|
+
wsName = ws.ID
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for _, chunk := range chunks {
|
|
256
|
+
// 每片重新读已有文档:上一片刚写的要点必须出现在这一片的「已有需求点」里,
|
|
257
|
+
// 否则同一件事会被重复追加。
|
|
258
|
+
docs := featureDocsOf(ws.ID)
|
|
259
|
+
prompt := buildPrompt(wsName, docs, chunk, listModules(ws.ID), opts.All)
|
|
260
|
+
text, err := call(prompt, outSchema)
|
|
261
|
+
res.Calls++
|
|
262
|
+
if err != nil {
|
|
263
|
+
res.Error = truncRunes(err.Error(), 240)
|
|
264
|
+
_ = writeState(ws.ID, withError(st, res.Error))
|
|
265
|
+
return res, err
|
|
266
|
+
}
|
|
267
|
+
outs, perr := parseFeatures(text)
|
|
268
|
+
if perr != nil {
|
|
269
|
+
res.Error = "LLM 输出不可解析: " + perr.Error()
|
|
270
|
+
_ = writeState(ws.ID, withError(st, res.Error))
|
|
271
|
+
return res, perr
|
|
272
|
+
}
|
|
273
|
+
if len(outs) == 0 {
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 新模块的同义二审:归纳是逐轮独立的,模型容易把同一件事拆成近义模块
|
|
278
|
+
//(「任务列表」/「对话列表」)。只有确认已有模块里没有同义项时才允许新建。
|
|
279
|
+
alias := map[string]string{}
|
|
280
|
+
existing := listModules(ws.ID)
|
|
281
|
+
newMods := []string{}
|
|
282
|
+
seenNew := map[string]bool{}
|
|
283
|
+
for _, o := range outs {
|
|
284
|
+
m := SlugifyModule(o.Module)
|
|
285
|
+
if m == "" || seenNew[m] || contains(existing, m) {
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
seenNew[m] = true
|
|
289
|
+
newMods = append(newMods, m)
|
|
290
|
+
}
|
|
291
|
+
if len(newMods) > 0 && len(existing) > 0 {
|
|
292
|
+
alias = resolveModuleAlias(call, newMods, existing)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for _, o := range outs {
|
|
296
|
+
w := writeFeatureItem(ws.ID, o, &writeCtx{
|
|
297
|
+
merger: merger,
|
|
298
|
+
alias: alias,
|
|
299
|
+
kind: "归纳",
|
|
300
|
+
all: opts.All,
|
|
301
|
+
sourcesOf: func(x featureOut) []string {
|
|
302
|
+
out := make([]string, 0, len(x.Sources))
|
|
303
|
+
for _, n := range x.Sources {
|
|
304
|
+
e, ok := byN[n]
|
|
305
|
+
if !ok {
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
out = append(out, fmt.Sprintf("[%s] #%d %s", fmtTs(e.Ts), e.N, truncRunes(collapseSpace(e.Text), SourceChars)))
|
|
309
|
+
}
|
|
310
|
+
return out
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
if !w.ok {
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
res.Points += w.points
|
|
317
|
+
res.Conclusions += w.conclusions
|
|
318
|
+
if w.created {
|
|
319
|
+
res.Created++
|
|
320
|
+
} else {
|
|
321
|
+
res.Updated++
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
consumed := cursor
|
|
327
|
+
if !opts.All {
|
|
328
|
+
consumed = cursor + len(use)
|
|
329
|
+
if consumed > len(mem) {
|
|
330
|
+
consumed = len(mem)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
st.Consumed = consumed
|
|
334
|
+
st.LastConsolidatedAt = time.Now().UnixMilli()
|
|
335
|
+
st.LastError = ""
|
|
336
|
+
if err := writeState(ws.ID, st); err != nil {
|
|
337
|
+
res.Error = "写状态失败: " + err.Error()
|
|
338
|
+
return res, err
|
|
339
|
+
}
|
|
340
|
+
res.OK = true
|
|
341
|
+
res.Consumed = 0
|
|
342
|
+
if !opts.All {
|
|
343
|
+
res.Consumed = len(use)
|
|
344
|
+
}
|
|
345
|
+
res.Replayed = 0
|
|
346
|
+
if opts.All {
|
|
347
|
+
res.Replayed = len(use)
|
|
348
|
+
}
|
|
349
|
+
res.Pending = len(mem) - consumed
|
|
350
|
+
if res.Pending < 0 {
|
|
351
|
+
res.Pending = 0
|
|
352
|
+
}
|
|
353
|
+
return res, nil
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// errBusy 另一个进程正在归纳(不是错误,是「这次跳过」)。
|
|
357
|
+
var errBusy = fmt.Errorf("requirements: busy")
|
|
358
|
+
|
|
359
|
+
// IsBusy 判断一轮归纳是否因为别的进程持锁而被跳过。
|
|
360
|
+
func IsBusy(err error) bool { return err == errBusy }
|
|
361
|
+
|
|
362
|
+
// llmCall 一次模型调用(prompt + 可选结构化契约 → 模型正文)。
|
|
363
|
+
type llmCall func(prompt string, schema *agent.JSONSchema) (string, error)
|
|
364
|
+
|
|
365
|
+
// newCaller 构造实际调用器。
|
|
366
|
+
//
|
|
367
|
+
// 抽成一层是为了让单测能灌一个假模型 —— 归纳真正容易错的地方是**合并与游标**
|
|
368
|
+
// (骨架解析、去重、来源回查、消费到第几条),那些逻辑不该因为「跑测试要真调模型」
|
|
369
|
+
// 而没人验。真机验收走的就是这里的默认实现。
|
|
370
|
+
var newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
|
|
371
|
+
engine := agent.Lookup(engineName)
|
|
372
|
+
if engine == nil {
|
|
373
|
+
return nil, fmt.Errorf("未知引擎: %s", engineName)
|
|
374
|
+
}
|
|
375
|
+
return func(prompt string, schema *agent.JSONSchema) (string, error) {
|
|
376
|
+
cctx, cancel := context.WithTimeout(ctx, timeout)
|
|
377
|
+
defer cancel()
|
|
378
|
+
runner := &agent.Runner{Engine: engine, Timeout: timeout}
|
|
379
|
+
resp, err := runner.Run(cctx, agent.Request{
|
|
380
|
+
Messages: []agent.Message{{Role: "user", Content: prompt}},
|
|
381
|
+
Tools: agent.ToolsOff,
|
|
382
|
+
Model: model,
|
|
383
|
+
JSONSchema: schema,
|
|
384
|
+
Timeout: timeout,
|
|
385
|
+
})
|
|
386
|
+
if err != nil {
|
|
387
|
+
return "", err
|
|
388
|
+
}
|
|
389
|
+
return strings.TrimSpace(resp.Text), nil
|
|
390
|
+
}, nil
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// chunkEntries 分片。
|
|
394
|
+
//
|
|
395
|
+
// 增量轮不分片(≤80 条、且只输出「有变化的」那几个功能点);
|
|
396
|
+
// 全量重放必须分:一次喂全部历史时模型要一口气输出所有功能点,输出会被上限截断。
|
|
397
|
+
func chunkEntries(use []Entry, all bool) [][]Entry {
|
|
398
|
+
if !all {
|
|
399
|
+
if len(use) == 0 {
|
|
400
|
+
return [][]Entry{{}}
|
|
401
|
+
}
|
|
402
|
+
return [][]Entry{use}
|
|
403
|
+
}
|
|
404
|
+
if len(use) == 0 {
|
|
405
|
+
return [][]Entry{{}}
|
|
406
|
+
}
|
|
407
|
+
out := make([][]Entry, 0, (len(use)+ReplayChunk-1)/ReplayChunk)
|
|
408
|
+
for i := 0; i < len(use); i += ReplayChunk {
|
|
409
|
+
end := i + ReplayChunk
|
|
410
|
+
if end > len(use) {
|
|
411
|
+
end = len(use)
|
|
412
|
+
}
|
|
413
|
+
out = append(out, use[i:end])
|
|
414
|
+
}
|
|
415
|
+
return out
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// existsDoc 已有功能点(喂 prompt 用:只给模块 + 名称 + 已有要点/结论)。
|
|
419
|
+
type existsDoc struct {
|
|
420
|
+
Module string
|
|
421
|
+
Name string
|
|
422
|
+
Doc Doc
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
func featureDocsOf(id string) []existsDoc {
|
|
426
|
+
files := listFeatureFiles(id)
|
|
427
|
+
out := make([]existsDoc, 0, len(files))
|
|
428
|
+
for _, f := range files {
|
|
429
|
+
d := Doc{}
|
|
430
|
+
if b, err := os.ReadFile(f.File); err == nil {
|
|
431
|
+
d = ParseDoc(string(b))
|
|
432
|
+
}
|
|
433
|
+
out = append(out, existsDoc{Module: f.Module, Name: f.Name, Doc: d})
|
|
434
|
+
}
|
|
435
|
+
return out
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// buildPrompt 组归纳 prompt。
|
|
439
|
+
//
|
|
440
|
+
// 规则段是**产品口径**,不是技术参数:哪些话不能写进需求点(缺陷措辞)、
|
|
441
|
+
// 什么样的模块必须复用、结论与问题的界限。这些是业务项目半年里一条条纠正出来的,
|
|
442
|
+
// 换实现时原样保留,别按自己的口味重写。
|
|
443
|
+
func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []string, replay bool) string {
|
|
444
|
+
mods := make([]string, 0, len(modules))
|
|
445
|
+
for _, m := range modules {
|
|
446
|
+
if strings.TrimSpace(m) != "" {
|
|
447
|
+
mods = append(mods, m)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
var fb strings.Builder
|
|
451
|
+
if len(docs) == 0 {
|
|
452
|
+
fb.WriteString("(暂无功能点,可按需新建)")
|
|
453
|
+
} else {
|
|
454
|
+
for i, d := range docs {
|
|
455
|
+
if i > 0 {
|
|
456
|
+
fb.WriteString("\n")
|
|
457
|
+
}
|
|
458
|
+
if d.Module != "" {
|
|
459
|
+
fb.WriteString("[模块 " + d.Module + "] ")
|
|
460
|
+
} else {
|
|
461
|
+
fb.WriteString("[未归类] ")
|
|
462
|
+
}
|
|
463
|
+
fb.WriteString(d.Name + "\n")
|
|
464
|
+
if len(d.Doc.Points) > 0 {
|
|
465
|
+
fb.WriteString(" 已有需求点:\n")
|
|
466
|
+
for j, p := range d.Doc.Points {
|
|
467
|
+
fb.WriteString(" " + strconv.Itoa(j+1) + ") " + truncRunes(p, 40) + "\n")
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if len(d.Doc.Conclusions) > 0 {
|
|
471
|
+
fb.WriteString(" 已有结论:\n")
|
|
472
|
+
for _, c := range d.Doc.Conclusions {
|
|
473
|
+
fb.WriteString(" · " + truncRunes(c, 40) + "\n")
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
var mb strings.Builder
|
|
479
|
+
for i, e := range entries {
|
|
480
|
+
if i > 0 {
|
|
481
|
+
mb.WriteString("\n")
|
|
482
|
+
}
|
|
483
|
+
mb.WriteString("#" + strconv.Itoa(e.N) + " [" + fmtTs(e.Ts) + "]")
|
|
484
|
+
if t := strings.TrimSpace(e.Title); t != "" {
|
|
485
|
+
mb.WriteString("(任务: " + t + ")")
|
|
486
|
+
}
|
|
487
|
+
mb.WriteString(" " + collapseSpace(e.Text))
|
|
488
|
+
}
|
|
489
|
+
memBlock := mb.String()
|
|
490
|
+
if memBlock == "" {
|
|
491
|
+
memBlock = "(无)"
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
rules := []string{
|
|
495
|
+
"0. 本次只给了**新增的那一批**记忆,按下面的规则归纳即可。",
|
|
496
|
+
"1. 每条需求都必须归入某个 module(大功能点)—— module 是必填字段;",
|
|
497
|
+
"2. module 优先复用(**硬约束**):必须先从上面「已有模块」里挑一个。判据是**语义是否相同**," +
|
|
498
|
+
"而不是字面是否相近 —— 只要已有模块表达了同一件事,就必须复用它,哪怕你觉得另起一个名字更贴切:",
|
|
499
|
+
" · 「任务列表」与「对话列表」都指\"列表\"这件事 → **是同一个模块**,必须复用已有的那个;",
|
|
500
|
+
" · 「对话交互」与「对话列表」一个指交互行为、一个指列表展示 → 不是一回事,可以并存;",
|
|
501
|
+
" 只有「已有模块」里**没有任何一个**表达相同含义时,才新建 module(不超过 " + strconv.Itoa(ModuleMaxChars) + " 个汉字)。",
|
|
502
|
+
" module 是**产品模块名**(如「任务列表」「对话交互」「需求管理」「应用打包」)," +
|
|
503
|
+
"不是需求描述、不是动词短语。宁可归到一个稍宽泛的已有模块,也不要新建近义模块;",
|
|
504
|
+
"3. 功能点优先复用:属于某个已有功能点时,**逐字沿用它的 name 与 module**,只输出新增内容;",
|
|
505
|
+
"4. 只输出**增量**(代码会按文本去重,所以「已有」的那条**逐字照抄**、绝不改写):",
|
|
506
|
+
" · desc:该功能点的一句话需求描述(产品视角,说清\"用户拿它做什么 / 期望是什么样\"," +
|
|
507
|
+
"不是缺陷描述)。已有描述且本次无变化 → 给空串 \"\";",
|
|
508
|
+
" · points:需求点,每条 ≤30 字。⚠️ **必须是「需求」,不是「bug 清单」**:",
|
|
509
|
+
" 写「这个功能**应当**是什么样」(用户能做什么 / 会发生什么),而不是「哪里坏了、要修什么」。",
|
|
510
|
+
" **不许出现**:「修复 X」「X 有问题 / 不正确 / 错误 / 异常」「X 仍然存在 / 仍可见」" +
|
|
511
|
+
"「应改为 …」「排查 / 优化 X」这类**现象或缺陷措辞** —— 那描述的是现状,不是需求。",
|
|
512
|
+
" 要从现象里**提炼出期望行为**再写;提炼不出来(用户只是笼统说「有问题」)就**不写这条**:",
|
|
513
|
+
" 「npc有问题 同时玩家直接穿进来了」→「玩家撞到 NPC 会被挡住,不能直接穿过去」",
|
|
514
|
+
" 「允许进 但是实际挡住了 应该进不去」→「被 NPC 挡住的位置一律进不去,没有能穿过去的缺口」",
|
|
515
|
+
" 与「已有需求点」**语义相同的一律逐字照抄已有那条**(不要同义改写 —— 改写会变成重复项);" +
|
|
516
|
+
"只有确实缺失的才新增;",
|
|
517
|
+
" · conclusions:**已经定下来**的结论 / 决策 / 取舍 / 约束,每条 ≤30 字。" +
|
|
518
|
+
"只写记忆里**明确表达过**的(用户拍板的、明确说不要的、选定的方案);" +
|
|
519
|
+
"不要写建议、不要推测、不要写\"待定/需确认\";没有就给空数组 [];",
|
|
520
|
+
" ⚠️ **现象与问题描述不是结论**:像「删除任务后列表仍可见,需修复」这种是**问题**," +
|
|
521
|
+
"不要当结论写进去;结论必须是「已经定下来的做法/取舍」。",
|
|
522
|
+
" 与「已有结论」语义相同的同样逐字照抄;**已有结论为空但记忆里有明确结论的必须补上**;",
|
|
523
|
+
" · sources:本条目引用的记忆编号(整数数组,取自上面 `#编号`);",
|
|
524
|
+
"5. 只归纳记忆里真实出现的内容,不要发明未提到的功能,**不要写任何日期**;",
|
|
525
|
+
"6. 没有任何新信息时输出空数组 features: []。",
|
|
526
|
+
}
|
|
527
|
+
if replay {
|
|
528
|
+
rules[0] = "0. **本次是全量重放**:上面给的是该工作区的**全部历史记忆**(不是新增的那一批)。" +
|
|
529
|
+
"判据是「这个功能点还能不能更完整」而不是「有没有新东西」——" +
|
|
530
|
+
"把记忆里涉及到的功能点**都输出一遍**(哪怕只补 desc):" +
|
|
531
|
+
"desc 可用记忆重新校核改写(这是唯一允许改写 desc 的场景)," +
|
|
532
|
+
"需求点/结论里已有的一字不改照抄、缺的补上。"
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
var b strings.Builder
|
|
536
|
+
b.WriteString("你是资深产品经理,负责把「对话记忆」归纳成**两级**功能点需求文档的**增量**。工作区:" + wsName + "\n\n")
|
|
537
|
+
b.WriteString("== 已有模块(大功能点 · 一级)==\n")
|
|
538
|
+
if len(mods) == 0 {
|
|
539
|
+
b.WriteString("(暂无模块,可新建)")
|
|
540
|
+
} else {
|
|
541
|
+
b.WriteString(strings.Join(mods, "、"))
|
|
542
|
+
}
|
|
543
|
+
b.WriteString("\n\n== 已有功能点(模块 + 名称 + 已有需求点/结论 · 二级 · 用于去重)==\n")
|
|
544
|
+
b.WriteString(fb.String())
|
|
545
|
+
b.WriteString("\n== 新增对话记忆(`#编号` 用于「来源」引用)==\n")
|
|
546
|
+
b.WriteString(memBlock)
|
|
547
|
+
b.WriteString("\n\n== 归纳规则 ==\n")
|
|
548
|
+
b.WriteString(strings.Join(rules, "\n"))
|
|
549
|
+
b.WriteString("\n\n只输出 JSON:{\"features\":[{\"module\":\"≤" + strconv.Itoa(ModuleMaxChars) +
|
|
550
|
+
"字大功能点\",\"name\":\"≤" + strconv.Itoa(TitleMaxChars) +
|
|
551
|
+
"字要点\",\"desc\":\"\",\"points\":[\"≤30字\"],\"conclusions\":[\"≤30字\"],\"sources\":[12]}]}")
|
|
552
|
+
return b.String()
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// aliasSchema 模块同义二审的输出契约。
|
|
556
|
+
var aliasSchema = &agent.JSONSchema{
|
|
557
|
+
Type: "object",
|
|
558
|
+
Properties: map[string]map[string]any{
|
|
559
|
+
"pairs": {
|
|
560
|
+
"type": "array",
|
|
561
|
+
"items": map[string]any{
|
|
562
|
+
"type": "object",
|
|
563
|
+
"properties": map[string]any{
|
|
564
|
+
"from": map[string]any{"type": "string"},
|
|
565
|
+
"to": map[string]any{"type": "string"},
|
|
566
|
+
},
|
|
567
|
+
"required": []string{"from", "to"},
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
Required: []string{"pairs"},
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// resolveModuleAlias 把本轮新出现的模块名和已有模块做语义比对,同义的归一到已有模块。
|
|
575
|
+
//
|
|
576
|
+
// 失败一律返回空表(本轮按原名新建)—— 二审是质量增强,不该让归纳整轮失败。
|
|
577
|
+
func resolveModuleAlias(call func(string, *agent.JSONSchema) (string, error), cands, existing []string) map[string]string {
|
|
578
|
+
if len(cands) == 0 || len(existing) == 0 {
|
|
579
|
+
return map[string]string{}
|
|
580
|
+
}
|
|
581
|
+
prompt := []string{
|
|
582
|
+
"你是资深产品经理。下面是同一个产品工作区里【已有模块】和【本轮新增模块】。",
|
|
583
|
+
"请找出「新增模块」中哪些其实与某个已有模块表达**同一件事**(语义相同,不是字面相近)。",
|
|
584
|
+
"判据示例:「任务列表」与「对话列表」都指\"列表\"这件事 → 同一件事;" +
|
|
585
|
+
"「对话交互」指交互行为、「对话列表」指列表展示 → 不是同一件事。",
|
|
586
|
+
"",
|
|
587
|
+
"== 已有模块 ==\n" + strings.Join(existing, "、"),
|
|
588
|
+
"",
|
|
589
|
+
"== 本轮新增模块 ==\n" + strings.Join(cands, "、"),
|
|
590
|
+
"",
|
|
591
|
+
"规则:to 必须**原样**取自「已有模块」;from 必须**原样**取自「本轮新增模块」;" +
|
|
592
|
+
"没有同义的输出空数组。",
|
|
593
|
+
`只输出 JSON:{"pairs":[{"from":"新增模块","to":"已有模块"}]}`,
|
|
594
|
+
}
|
|
595
|
+
text, err := call(strings.Join(prompt, "\n"), aliasSchema)
|
|
596
|
+
if err != nil {
|
|
597
|
+
return map[string]string{}
|
|
598
|
+
}
|
|
599
|
+
obj, ok := firstJSONObject(text)
|
|
600
|
+
if !ok {
|
|
601
|
+
return map[string]string{}
|
|
602
|
+
}
|
|
603
|
+
var pairs struct {
|
|
604
|
+
Pairs []struct {
|
|
605
|
+
From string `json:"from"`
|
|
606
|
+
To string `json:"to"`
|
|
607
|
+
} `json:"pairs"`
|
|
608
|
+
}
|
|
609
|
+
if json.Unmarshal([]byte(obj), &pairs) != nil {
|
|
610
|
+
return map[string]string{}
|
|
611
|
+
}
|
|
612
|
+
out := map[string]string{}
|
|
613
|
+
for _, p := range pairs.Pairs {
|
|
614
|
+
from, to := strings.TrimSpace(p.From), strings.TrimSpace(p.To)
|
|
615
|
+
// 双向校验:模型偶尔会把 to 编一个不存在的模块名 —— 只认已有模块里的原值。
|
|
616
|
+
if from == "" || to == "" || from == to || !contains(existing, to) || !contains(cands, from) {
|
|
617
|
+
continue
|
|
618
|
+
}
|
|
619
|
+
out[from] = to
|
|
620
|
+
}
|
|
621
|
+
return out
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// parseFeatures 从模型输出里取 features 数组。
|
|
625
|
+
//
|
|
626
|
+
// 容忍 markdown 围栏与前后废话(不同引擎的正文包裹方式不一样);拿不到就报错,
|
|
627
|
+
// 让上层把「不可解析」记进 state 的 last_error —— 这比静默 noop 强得多。
|
|
628
|
+
func parseFeatures(text string) ([]featureOut, error) {
|
|
629
|
+
raw := strings.TrimSpace(text)
|
|
630
|
+
if obj, ok := firstJSONObject(raw); ok {
|
|
631
|
+
var wrap struct {
|
|
632
|
+
Features []featureOut `json:"features"`
|
|
633
|
+
}
|
|
634
|
+
if err := json.Unmarshal([]byte(obj), &wrap); err == nil {
|
|
635
|
+
return wrap.Features, nil
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// 退一步:模型可能只给了数组本身。
|
|
639
|
+
if i := strings.Index(raw, "["); i >= 0 {
|
|
640
|
+
if j := strings.LastIndex(raw, "]"); j > i {
|
|
641
|
+
var arr []featureOut
|
|
642
|
+
if err := json.Unmarshal([]byte(raw[i:j+1]), &arr); err == nil {
|
|
643
|
+
return arr, nil
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return nil, fmt.Errorf("缺少 features 数组")
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// firstJSONObject 从文本里找首个完整的顶层 JSON 对象(去围栏、容忍前后废话)。
|
|
651
|
+
func firstJSONObject(s string) (string, bool) {
|
|
652
|
+
s = stripFence(s)
|
|
653
|
+
start := strings.IndexByte(s, '{')
|
|
654
|
+
if start < 0 {
|
|
655
|
+
return "", false
|
|
656
|
+
}
|
|
657
|
+
depth, inStr, escape := 0, false, false
|
|
658
|
+
for i := start; i < len(s); i++ {
|
|
659
|
+
c := s[i]
|
|
660
|
+
if inStr {
|
|
661
|
+
switch c {
|
|
662
|
+
case '\\':
|
|
663
|
+
escape = !escape
|
|
664
|
+
continue
|
|
665
|
+
case '"':
|
|
666
|
+
if !escape {
|
|
667
|
+
inStr = false
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
escape = false
|
|
671
|
+
continue
|
|
672
|
+
}
|
|
673
|
+
switch c {
|
|
674
|
+
case '"':
|
|
675
|
+
inStr = true
|
|
676
|
+
case '{':
|
|
677
|
+
depth++
|
|
678
|
+
case '}':
|
|
679
|
+
depth--
|
|
680
|
+
if depth == 0 {
|
|
681
|
+
return s[start : i+1], true
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return "", false
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// stripFence 去掉整段 markdown 代码围栏。
|
|
689
|
+
func stripFence(s string) string {
|
|
690
|
+
t := strings.TrimSpace(s)
|
|
691
|
+
if !strings.HasPrefix(t, "```") {
|
|
692
|
+
return t
|
|
693
|
+
}
|
|
694
|
+
if i := strings.IndexByte(t, '\n'); i > 0 {
|
|
695
|
+
t = t[i+1:]
|
|
696
|
+
}
|
|
697
|
+
if j := strings.LastIndex(t, "```"); j > 0 {
|
|
698
|
+
t = t[:j]
|
|
699
|
+
}
|
|
700
|
+
return strings.TrimSpace(t)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// writeCtx 写文档的上下文(归纳与将来的「反推」共用同一套写入口径)。
|
|
704
|
+
type writeCtx struct {
|
|
705
|
+
merger *docMerger
|
|
706
|
+
alias map[string]string
|
|
707
|
+
kind string
|
|
708
|
+
all bool
|
|
709
|
+
sourcesOf func(featureOut) []string
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// writeResult 一条增量的落盘结果。
|
|
713
|
+
type writeResult struct {
|
|
714
|
+
ok bool
|
|
715
|
+
created bool
|
|
716
|
+
points int
|
|
717
|
+
conclusions int
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// writeFeatureItem 把一条增量合并进功能点文档。
|
|
721
|
+
//
|
|
722
|
+
// 这里埋着四个**实测踩过**的坑,处理方式逐条对应:
|
|
723
|
+
// - 同名匹配三档(模型把已有名写长再被截断会凭空多一篇)→ docMerger.match;
|
|
724
|
+
// - 同一轮里同一功能点被输出两次(会撞 slug 生成 xxx-2.md)→ 命中已有文档就原地合并;
|
|
725
|
+
// - slug 撞车(两个不同功能点截断后同名)→ usedFile 错开编号;
|
|
726
|
+
// - 占位符回吐(模型把「(暂无)」当内容)→ 写入前过滤 + RenderDoc 再滤一次。
|
|
727
|
+
func writeFeatureItem(id string, out featureOut, ctx *writeCtx) writeResult {
|
|
728
|
+
name := strings.TrimSpace(truncRunes(strings.TrimSpace(out.Name), TitleMaxChars))
|
|
729
|
+
if name == "" {
|
|
730
|
+
return writeResult{}
|
|
731
|
+
}
|
|
732
|
+
hit, has := ctx.merger.match(name)
|
|
733
|
+
|
|
734
|
+
addPoints := cleanList(out.Points)
|
|
735
|
+
addConcs := cleanList(out.Conclusions)
|
|
736
|
+
addDesc := strings.TrimSpace(out.Desc)
|
|
737
|
+
if isPlaceholder(addDesc) {
|
|
738
|
+
addDesc = ""
|
|
739
|
+
}
|
|
740
|
+
if !has && addDesc == "" && len(addPoints) == 0 && len(addConcs) == 0 {
|
|
741
|
+
return writeResult{} // 只有个名字、没有内容:不写空壳
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// 归属:已有文档沿用它的模块(不搬家);只有新文档才采纳模型给的 module。
|
|
745
|
+
rawMod := SlugifyModule(out.Module)
|
|
746
|
+
if has {
|
|
747
|
+
rawMod = SlugifyModule(hit.Module)
|
|
748
|
+
}
|
|
749
|
+
if a, ok := ctx.alias[rawMod]; ok {
|
|
750
|
+
rawMod = a
|
|
751
|
+
}
|
|
752
|
+
dir := featuresDir(id)
|
|
753
|
+
if rawMod != "" {
|
|
754
|
+
dir = filepath.Join(dir, rawMod)
|
|
755
|
+
}
|
|
756
|
+
base := SlugifyFeature(name)
|
|
757
|
+
full := ""
|
|
758
|
+
if has {
|
|
759
|
+
full = hit.File
|
|
760
|
+
} else {
|
|
761
|
+
full = filepath.Join(dir, base+".md")
|
|
762
|
+
for k := 2; ctx.merger.usedFile[full]; k++ {
|
|
763
|
+
full = filepath.Join(dir, base+"-"+strconv.Itoa(k)+".md")
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
ctx.merger.usedFile[full] = true
|
|
767
|
+
|
|
768
|
+
prev := Doc{}
|
|
769
|
+
if has {
|
|
770
|
+
if b, err := os.ReadFile(hit.File); err == nil {
|
|
771
|
+
prev = ParseDoc(string(b))
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
desc := addDesc
|
|
775
|
+
if desc == "" {
|
|
776
|
+
desc = prev.Desc
|
|
777
|
+
}
|
|
778
|
+
points := mergeUnique(prev.Points, addPoints, MaxPoints, true, nil)
|
|
779
|
+
conclusions := mergeUnique(prev.Conclusions, addConcs, MaxConclusions, true, nil)
|
|
780
|
+
srcLines := []string{}
|
|
781
|
+
if ctx.sourcesOf != nil {
|
|
782
|
+
srcLines = ctx.sourcesOf(out)
|
|
783
|
+
}
|
|
784
|
+
sources := mergeUnique(prev.Sources, srcLines, MaxSources, false, sourceKey)
|
|
785
|
+
|
|
786
|
+
dPts := len(points) - len(prev.Points)
|
|
787
|
+
if dPts < 0 {
|
|
788
|
+
dPts = 0
|
|
789
|
+
}
|
|
790
|
+
dCcs := len(conclusions) - len(prev.Conclusions)
|
|
791
|
+
if dCcs < 0 {
|
|
792
|
+
dCcs = 0
|
|
793
|
+
}
|
|
794
|
+
line := todayStr() + " " + ctx.kind + ":新增需求点 " + strconv.Itoa(dPts) +
|
|
795
|
+
" 条 · 结论 " + strconv.Itoa(dCcs) + " 条"
|
|
796
|
+
if ctx.all {
|
|
797
|
+
line += "(全量重放)"
|
|
798
|
+
}
|
|
799
|
+
if len(out.Sources) > 0 {
|
|
800
|
+
parts := make([]string, 0, len(out.Sources))
|
|
801
|
+
for _, n := range out.Sources {
|
|
802
|
+
parts = append(parts, "#"+strconv.Itoa(n))
|
|
803
|
+
}
|
|
804
|
+
line += " · 来源 " + strings.Join(parts, " ")
|
|
805
|
+
}
|
|
806
|
+
logLines := mergeUnique(prev.Log, []string{line}, MaxLogLines, false, nil)
|
|
807
|
+
|
|
808
|
+
doc := Doc{
|
|
809
|
+
Final: renderFinal(desc, conclusions),
|
|
810
|
+
Desc: desc,
|
|
811
|
+
Points: points,
|
|
812
|
+
Conclusions: conclusions,
|
|
813
|
+
Sources: sources,
|
|
814
|
+
Log: logLines,
|
|
815
|
+
}
|
|
816
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
817
|
+
return writeResult{}
|
|
818
|
+
}
|
|
819
|
+
if err := os.WriteFile(full, []byte(RenderDoc(name, doc)), 0o600); err != nil {
|
|
820
|
+
return writeResult{}
|
|
821
|
+
}
|
|
822
|
+
if !has {
|
|
823
|
+
ctx.merger.add(featureFile{Module: rawMod, Name: name, File: full})
|
|
824
|
+
}
|
|
825
|
+
return writeResult{ok: true, created: !has, points: dPts, conclusions: dCcs}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// renderFinal 从「需求描述 + 已定结论」收敛出「最终版」。
|
|
829
|
+
//
|
|
830
|
+
// 不调模型:它是**派生**内容(把分散的结论收成一眼能看的定稿),
|
|
831
|
+
// 而不是又一轮归纳 —— 多调一次模型只会多一份可能和需求点打架的措辞。
|
|
832
|
+
func renderFinal(desc string, conclusions []string) string {
|
|
833
|
+
var b strings.Builder
|
|
834
|
+
if d := strings.TrimSpace(desc); d != "" && !isPlaceholder(d) {
|
|
835
|
+
b.WriteString("**目标**:" + d + "\n")
|
|
836
|
+
}
|
|
837
|
+
if len(conclusions) > 0 {
|
|
838
|
+
if b.Len() > 0 {
|
|
839
|
+
b.WriteString("\n")
|
|
840
|
+
}
|
|
841
|
+
b.WriteString("**已定**:\n")
|
|
842
|
+
for _, c := range conclusions {
|
|
843
|
+
b.WriteString("- " + c + "\n")
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return strings.TrimSpace(b.String())
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// cleanList 过滤空串与占位符。
|
|
850
|
+
func cleanList(in []string) []string {
|
|
851
|
+
out := make([]string, 0, len(in))
|
|
852
|
+
for _, s := range in {
|
|
853
|
+
v := strings.TrimSpace(s)
|
|
854
|
+
if v == "" || isPlaceholder(v) {
|
|
855
|
+
continue
|
|
856
|
+
}
|
|
857
|
+
out = append(out, v)
|
|
858
|
+
}
|
|
859
|
+
return out
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// collapseSpace 把连续空白压成单个空格(记忆 / 来源行都要单行展示)。
|
|
863
|
+
func collapseSpace(s string) string { return strings.Join(strings.Fields(s), " ") }
|
|
864
|
+
|
|
865
|
+
// fmtTs unix 毫秒 → 「10-22 14:30」(来源行 / 记忆清单同一口径)。
|
|
866
|
+
func fmtTs(ms int64) string {
|
|
867
|
+
if ms <= 0 {
|
|
868
|
+
return "--"
|
|
869
|
+
}
|
|
870
|
+
return time.UnixMilli(ms).Format("1-02 15:04")
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// todayStr 今天(更新记录用;日期只由代码写,模型不参与)。
|
|
874
|
+
func todayStr() string { return time.Now().Format("2006-01-02") }
|
|
875
|
+
|
|
876
|
+
// withError 返回一份带失败原因的状态副本。
|
|
877
|
+
func withError(st state, msg string) state {
|
|
878
|
+
st.LastError = truncRunes(msg, 240)
|
|
879
|
+
return st
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// acquireLock 取工作区归纳锁;已被占用返回 busy=true。
|
|
883
|
+
//
|
|
884
|
+
// 用 O_EXCL 建锁文件(原子),内容写 pid + 时间:
|
|
885
|
+
// 进程被 kill 会留下锁,超过 lockStale 视为陈旧可抢占。
|
|
886
|
+
func acquireLock(id string) (release func(), busy bool) {
|
|
887
|
+
f := filepath.Join(wsDir(id), "state.lock")
|
|
888
|
+
_ = os.MkdirAll(wsDir(id), 0o700)
|
|
889
|
+
if b, err := os.ReadFile(f); err == nil {
|
|
890
|
+
var o struct {
|
|
891
|
+
PID int `json:"pid"`
|
|
892
|
+
At int64 `json:"at"`
|
|
893
|
+
}
|
|
894
|
+
_ = json.Unmarshal(b, &o)
|
|
895
|
+
if o.At > 0 && time.Since(time.UnixMilli(o.At)) < lockStale {
|
|
896
|
+
return func() {}, true
|
|
897
|
+
}
|
|
898
|
+
_ = os.Remove(f) // 陈旧锁:抢占
|
|
899
|
+
}
|
|
900
|
+
fh, err := os.OpenFile(f, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
901
|
+
if err != nil {
|
|
902
|
+
return func() {}, true // 抢不到就是有人在写
|
|
903
|
+
}
|
|
904
|
+
b, _ := json.Marshal(map[string]any{"pid": os.Getpid(), "at": time.Now().UnixMilli()})
|
|
905
|
+
_, _ = fh.Write(b)
|
|
906
|
+
_ = fh.Close()
|
|
907
|
+
return func() { _ = os.Remove(f) }, false
|
|
908
|
+
}
|