Skip to content

Commit 856cd91

Browse files
lin-snowclaude
andcommitted
feat(copilot): 年终/区间总结——summarize_echos 工具 + 窗口自适应 map-reduce
把「年终/月度回顾」这类穷举聚合从 top-k 检索里拆出来,做成独立工具: - 新增 summarize_echos:按区间分页取全(硬上限 5000,截断保留最近并如实标注), 据模型窗口自适应——放得下整段塞入、放不下按月 map-reduce 分层浓缩。 - AgentSetting 新增可选 context_window(前端以 256k/1m 友好单位填写、解析存 token, 默认按 256k 处理);驱动聚合取数预算。 - 覆盖度随结果回报:新增 SSE coverage 事件 + 前端「📚 已覆盖 N 条」状态条,杜绝静默截断。 - search_echos 顺带回报命中总数,点查路径也不再把采样当全部。 - prompt 路由:总结/回顾走 summarize_echos,点查走 search_echos(ZH/EN)。 - 前端加「年终总结」预设建议;i18n 四语补齐;设计文档新增 §18。 注:本提交同时捕获工作区中既有、尚未提交的 Copilot agent toolcall 实现 (agent loop / provider / search-enrich 拆分 / 配置与 wire 等),以保持可构建的完整状态; 如需拆分历史可在本分支上 reset --soft 重组。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 25eda8b commit 856cd91

39 files changed

Lines changed: 1756 additions & 374 deletions

docs/dev/agent-toolcall-design.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ emit AgentDone // 到达 maxRounds 仍未收尾,强制结束(已产出的
325325
- 封 3 轮下:典型"搜一次再答"≈ 2 次模型调用、token/延迟 **2–4×**;多搜 ≈ 4–6×(上下文累积近平方增长)。
326326
- 缓解:system + tool 定义走 **prompt cache**(OpenAI/Anthropic 支持);工具结果保持精简(仅文本快照);查询去重;丢最旧 tool 结果。
327327
- 新增可选环境变量(命名待定):`ECH0_CHAT_MAX_ROUNDS``ECH0_CHAT_TOKEN_BUDGET``ECH0_CHAT_TOOL_TIMEOUT`
328+
- **`summarize_echos`(§18)的额外成本**:聚合一次最多拉 `maxAggregateEchos=5000` 条;map-reduce 的 LLM 调用数 ≈ 月份数(+1 次可选 reduce),但只在「窗口放不下」时触发——窗口足够(如 1M)则零额外调用、单次塞入。成本随 `AgentSetting.ContextWindow` 自适应。
328329

329330
## 14. 分期实施(建议顺序)
330331

@@ -343,6 +344,8 @@ emit AgentDone // 到达 maxRounds 仍未收尾,强制结束(已产出的
343344
| 2 | 是否加 `keyword_search`(FTS) 作第二工具 | **本期只做 vector search**。无 embedding 兜底议题另案处理,不进本重构。 |
344345
| 3 | v1 是否开启多轮历史 | **先单轮验证工具体验**。历史接入点已预留(§11),跑通后再开多轮。 |
345346
| 4 | Gemini 协议去留 | **整协议下线(方案 B)**`agent` 仅保留 OpenAI 兼容 + Anthropic(见 §15.1)。 |
347+
| 5 | 年终/区间总结怎么做 | **独立工具 `summarize_echos` + 窗口自适应 map-reduce**,不把 `search_echos` 撑成万能(见 §18)。 |
348+
| 6 | 模型窗口如何感知 | **`AgentSetting` 新增可选 `ContextWindow`**(0=按 256k 保守默认),驱动聚合取数预算(见 §18.3)。 |
346349

347350
### 15.1 Gemini 整协议下线(方案 B,已定)
348351

@@ -451,3 +454,57 @@ export function sseStream<E = unknown>(opts: {
451454
### 17.4 与本重构的衔接
452455

453456
此项与后端 Agent**正交但同区**M3 本就要改 chatSSE(新增 `searching``sources` 改累积)。**顺序上先抽 `sse.ts` 封装,再在干净封装上加新事件**,避免在旧手写 `chat.ts` 上叠加。故并入 §14M3(见该节)。`sse.ts` 封装本身不依赖后端,可独立先行。
457+
458+
## 18. 区间聚合与长文总结(年终 / 月度回顾)
459+
460+
> 本节是工具体系落地后的增量设计:让「年终 / 月度总结」这类**穷举聚合**任务做对。实现见 `internal/service/copilot/{aggregate,budget,search,chat,prompt}.go`
461+
462+
### 18.1 问题:top-k 检索 vs 穷举聚合的访问模式错配
463+
464+
`search_echos` 是为**点查(needle**设计的 top-k 语义检索:`defaultTopK=6` 固定、不暴露给模型、无翻页入参,`maxRounds=3`,且早期 `queryEchos` **丢弃了 `QueryEchos` 返回的 `Total`**。当用户说「帮我写一篇年终总结」时,模型把日期设为全年 → 只拿回最新 6 条 → 基于 6~18 条生成一篇「看着很完整」的总结。这是最坏的一种失败——**静默截断**:模型不知道自己只看到冰山一角,用户也不知道,产出却言之凿凿。
465+
466+
根因:**top-k 语义检索(找最相关的几条)与穷举聚合(把某区间内全部读一遍再归纳)是相反的访问模式**。再加上 `AgentSetting` 原本没有窗口字段,后端无从得知模型是 256k 还是 1M,大窗口能力被浪费。
467+
468+
### 18.2 原则
469+
470+
1. **聚合是独立访问模式 ⇒ 独立工具**:新增 `summarize_echos`,不把 `search_echos` 撑成万能。两者并行注入,模型按意图选择(§18.5)。
471+
2. **窗口自适应**:放得下就一次塞满(吃满大窗口),放不下才按月 map-reduce 分层——而非永远保守分层。
472+
3. **绝不静默截断**:覆盖度(命中总数 / 纳入条数 / 分桶数 / 是否截断)随结果回报给模型与用户(§18.6),呼应 §8「防静默失败」。
473+
474+
### 18.3 `AgentSetting.ContextWindow`token 预算
475+
476+
新增可选字段 `ContextWindow int`token0=未配置)。前端以 `256k`/`1m` 友好单位填写,解析成 token 数存储(`web/src/utils/tokenSize.ts`)。预算模型(`budget.go`):
477+
478+
```
479+
window = ContextWindow>0 ? ContextWindow : 256_000 // 默认保守 256k
480+
usable = max(window * 0.6 - 8_000, 2_000) // 扣掉 system/工具定义/成稿留白,留下塞物料的预算
481+
```
482+
483+
度量复用 `estimateTokens`(rune 计数,CJK≈1/字),不引 tokenizer。
484+
485+
### 18.4 `summarize_echos` 工具
486+
487+
- 入参:`date_from`/`date_to`(必填,区间)、`tags`(可选限定主题)、`focus`(可选侧重)。
488+
- `collectRange`:按 `created_at DESC` 分页(`PageSize=200`)穷举区间内**全部** Echo,累加到覆盖 `Total` 或触顶硬上限 `maxAggregateEchos=5000`(触顶因倒序保留**最近** N 条,并置 `truncated`)。
489+
- `mapReduceSummary`(窗口自适应):
490+
- `estimateTokens(全部) ≤ usable` → 直接返回完整格式化文本(`buckets=1`,大窗口在此吃满);
491+
- 否则按 `YYYY-MM` 分桶,每桶 `agent.Generate` 浓缩成事实性摘要(**map**);拼接后若仍超预算,再 `agent.Generate` 压一轮(**reduce**),保留每月要点与时间线。
492+
- map 阶段 v1 **顺序执行**(成本可预测);bounded 并行留作后续(§18.8)。任一调用失败上抛,由 Loop 作为「工具执行失败」回喂模型自愈(§8)。
493+
- 产出:`ToolOutput.Content` = 覆盖度抬头 + 聚合物料(供模型**写最终成稿**,工具不抢生成,tone/focus 可控、工具可复用);`Meta` = `aggregateCoverage`。
494+
495+
### 18.5 prompt 路由
496+
497+
`chatSystemPrompt`(ZH/EN)声明两个工具的分工:点查用 `search_echos`,「某段时间的总结/回顾(年终/年度/季度/月度)」用 `summarize_echos`(覆盖全区间,据当前日期换算 `date_from/date_to`)。并把「1~2 次足够」收敛为**只约束 `search_echos`**,不误伤聚合。
498+
499+
### 18.6 SSE `coverage` 事件(加法兼容)
500+
501+
`AgentToolResult` 的 `Meta` 按类型分流:`[]SearchResult` → 既有 `sources`;`aggregateCoverage` → 新增 `coverage`(payload `{total, returned, buckets, truncated}`)。旧前端忽略未知事件,符合「加法不替换」。前端以「📚 已覆盖该区间 N 条」状态条展示,截断时改提示「已覆盖最近 N 条」。
502+
503+
### 18.7 顺带修 search_echos 的静默截断
504+
505+
`queryEchos` 改为同时返回 `Total`;当 `Total > 展示条数` 时,在工具结果前补一行如实告知「共命中 N 条、仅展示 M 条,要覆盖全部请用 summarize_echos」。点查路径也不再把「采样」当「全部」。
506+
507+
### 18.8 成本与未来优化
508+
509+
- map-reduce 的 LLM 调用次数 ≈ 月份数(+1 次可选 reduce),受 `maxAggregateEchos` 与「放得下直接塞」双重收敛;普通用户一整年常落在「直接塞、零额外调用」。
510+
- **后续(本期不做,守轻量原则)**:① 月度 digest 缓存 + 事件失效(复用 `GetRecent` 的 `EchoCreated/Updated` 失效范式)大幅降重复年终请求成本;② map 阶段 bounded 并行降延迟;③ 独立「年度回顾」端点 + UI 入口(带缓存),把 chat 内的能力沉淀为一等公民功能。

docs/dev/development.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ Install [Swagger](https://github.com/swaggo/gin-swagger) to generate/use OpenAPI
3838
- `ECH0_EVENT_AGENT_BUFFER` / `ECH0_EVENT_AGENT_PARALLELISM`
3939
- `ECH0_EVENT_WEBHOOK_POOL_WORKERS` / `ECH0_EVENT_WEBHOOK_POOL_QUEUE`
4040

41+
📌 **Agent (Copilot) Parameters**
42+
- `ECH0_AGENT_TIMEOUT_SECONDS` — per-run timeout (seconds) for a single Copilot chat run, covering the whole tool loop; default `120`, `<=0` disables the extra timeout.
43+
4144
## Frontend Requirements
4245

4346
📌 **NodeJS v25.5.0+, PNPM v10.30.0+**

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u
191191
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
192192
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
193193
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
194+
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
195+
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
194196
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
195197
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
196198
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=

internal/agent/agent.go

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,13 @@ func applyPrompt(setting model.AgentSetting, in []Message, usePrompt bool) []Mes
4747
return in
4848
}
4949

50-
// temperatureOf 取首个可选 temperature(无则返回 nil)。
51-
func temperatureOf(temperature []float32) *float32 {
52-
if len(temperature) > 0 {
53-
return &temperature[0]
54-
}
55-
return nil
56-
}
57-
58-
// Generate 调用配置的 LLM 提供商生成回复(非流式)。
50+
// Generate 调用配置的 LLM 提供商生成回复(非流式)。temperature 为 nil 时不设置。
5951
func Generate(
6052
ctx context.Context,
6153
setting model.AgentSetting,
6254
in []Message,
6355
usePrompt bool,
64-
temperature ...float32,
56+
temperature *float32,
6557
) (string, error) {
6658
if err := validate(setting); err != nil {
6759
return "", err
@@ -74,7 +66,7 @@ func Generate(
7466

7567
resp, err := provider.Complete(ctx, Request{
7668
Messages: applyPrompt(setting, in, usePrompt),
77-
Temperature: temperatureOf(temperature),
69+
Temperature: temperature,
7870
})
7971
if err != nil {
8072
return "", err

internal/agent/provider_test.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2025-2026 lin-snow
3+
4+
package agent
5+
6+
import (
7+
"testing"
8+
9+
anthropic "github.com/anthropics/anthropic-sdk-go"
10+
openai "github.com/sashabaranov/go-openai"
11+
)
12+
13+
// 累积器把跨 chunk 的 arguments 分片按 index 拼回完整 JSON。
14+
func TestToolCallAccumulator_CrossChunk(t *testing.T) {
15+
a := newToolCallAccumulator()
16+
a.add([]openai.ToolCall{{Index: new(0), ID: "c1", Function: openai.FunctionCall{Name: "search"}}})
17+
a.add([]openai.ToolCall{{Index: new(0), Function: openai.FunctionCall{Arguments: `{"q"`}}})
18+
a.add([]openai.ToolCall{{Index: new(0), Function: openai.FunctionCall{Arguments: `:"x"}`}}})
19+
20+
got := a.finish()
21+
if len(got) != 1 {
22+
t.Fatalf("got %d tool calls, want 1", len(got))
23+
}
24+
if got[0].ID != "c1" || got[0].Name != "search" {
25+
t.Fatalf("id/name = %q/%q, want c1/search", got[0].ID, got[0].Name)
26+
}
27+
if string(got[0].Args) != `{"q":"x"}` {
28+
t.Fatalf("args = %s, want {\"q\":\"x\"}", got[0].Args)
29+
}
30+
}
31+
32+
// 多个 index 的调用按出现顺序保留。
33+
func TestToolCallAccumulator_MultipleIndicesPreserveOrder(t *testing.T) {
34+
a := newToolCallAccumulator()
35+
a.add([]openai.ToolCall{{Index: new(0), ID: "a", Function: openai.FunctionCall{Name: "first", Arguments: "{}"}}})
36+
a.add([]openai.ToolCall{{Index: new(1), ID: "b", Function: openai.FunctionCall{Name: "second", Arguments: "{}"}}})
37+
38+
got := a.finish()
39+
if len(got) != 2 || got[0].Name != "first" || got[1].Name != "second" {
40+
t.Fatalf("order not preserved: %+v", got)
41+
}
42+
}
43+
44+
// 无 arguments 分片时兜底成 "{}"。
45+
func TestToolCallAccumulator_EmptyArgsFallback(t *testing.T) {
46+
a := newToolCallAccumulator()
47+
a.add([]openai.ToolCall{{Index: new(0), ID: "a", Function: openai.FunctionCall{Name: "noargs"}}})
48+
49+
got := a.finish()
50+
if len(got) != 1 || string(got[0].Args) != "{}" {
51+
t.Fatalf("empty args should fall back to {}, got %+v", got)
52+
}
53+
}
54+
55+
// OpenAI 角色映射。
56+
func TestToOpenAIRole(t *testing.T) {
57+
cases := map[Role]string{
58+
RoleSystem: openai.ChatMessageRoleSystem,
59+
RoleAssistant: openai.ChatMessageRoleAssistant,
60+
RoleTool: openai.ChatMessageRoleTool,
61+
RoleUser: openai.ChatMessageRoleUser,
62+
Role("weird"): openai.ChatMessageRoleUser, // 未知角色兜底 user
63+
}
64+
for in, want := range cases {
65+
if got := toOpenAIRole(in); got != want {
66+
t.Fatalf("toOpenAIRole(%q) = %q, want %q", in, got, want)
67+
}
68+
}
69+
}
70+
71+
// OpenAI buildMessages:RoleTool 带 ToolCallID;RoleAssistant 的 ToolCalls 映射为 openai.ToolCall;
72+
// 带图消息走 MultiContent 而非 Content。
73+
func TestOpenAIBuildMessages(t *testing.T) {
74+
p := &openaiProvider{}
75+
in := []Message{
76+
{Role: RoleAssistant, Content: "calling", ToolCalls: []ToolCall{{ID: "c1", Name: "search", Args: []byte(`{"q":"x"}`)}}},
77+
{Role: RoleTool, ToolCallID: "c1", Content: "result"},
78+
{Role: RoleUser, Content: "see image", Images: []ImagePart{{MediaType: "image/png", Base64: "abc"}}},
79+
}
80+
msgs := p.buildMessages(in)
81+
if len(msgs) != 3 {
82+
t.Fatalf("got %d messages, want 3", len(msgs))
83+
}
84+
if len(msgs[0].ToolCalls) != 1 || msgs[0].ToolCalls[0].Function.Arguments != `{"q":"x"}` {
85+
t.Fatalf("assistant tool_calls not mapped: %+v", msgs[0].ToolCalls)
86+
}
87+
if msgs[1].ToolCallID != "c1" {
88+
t.Fatalf("tool message ToolCallID = %q, want c1", msgs[1].ToolCallID)
89+
}
90+
if msgs[2].Content != "" || len(msgs[2].MultiContent) == 0 {
91+
t.Fatalf("image message should use MultiContent, got Content=%q MultiContent=%+v", msgs[2].Content, msgs[2].MultiContent)
92+
}
93+
}
94+
95+
// openAIImageParts:Base64 转 data URL,纯 URL 直链透传,空文本不产文本块,空图跳过。
96+
func TestOpenAIImageParts(t *testing.T) {
97+
parts := openAIImageParts("hello", []ImagePart{
98+
{MediaType: "image/png", Base64: "abc"},
99+
{URL: "https://example.com/x.jpg"},
100+
{}, // 既无 Base64 也无 URL → 跳过
101+
})
102+
// 1 文本块 + 2 图片块
103+
if len(parts) != 3 {
104+
t.Fatalf("got %d parts, want 3 (1 text + 2 image)", len(parts))
105+
}
106+
if parts[0].Type != openai.ChatMessagePartTypeText || parts[0].Text != "hello" {
107+
t.Fatalf("first part should be text 'hello', got %+v", parts[0])
108+
}
109+
if parts[1].ImageURL == nil || parts[1].ImageURL.URL != "data:image/png;base64,abc" {
110+
t.Fatalf("base64 image should become data URL, got %+v", parts[1].ImageURL)
111+
}
112+
if parts[2].ImageURL == nil || parts[2].ImageURL.URL != "https://example.com/x.jpg" {
113+
t.Fatalf("url image should pass through, got %+v", parts[2].ImageURL)
114+
}
115+
116+
// 空文本不产文本块。
117+
noText := openAIImageParts("", []ImagePart{{Base64: "abc", MediaType: "image/png"}})
118+
if len(noText) != 1 || noText[0].Type != openai.ChatMessagePartTypeImageURL {
119+
t.Fatalf("empty text should yield only the image part, got %+v", noText)
120+
}
121+
}
122+
123+
// Anthropic buildMessages:连续的 RoleTool 合并进单条 user 消息(满足 tool_result 同处一条 user 的约束)。
124+
func TestAnthropicBuildMessages_ConsecutiveToolsMerge(t *testing.T) {
125+
p := &anthropicProvider{}
126+
in := []Message{
127+
{Role: RoleSystem, Content: "you are a bot"},
128+
{Role: RoleUser, Content: "q"},
129+
{Role: RoleAssistant, ToolCalls: []ToolCall{{ID: "c1", Name: "search", Args: []byte(`{}`)}}},
130+
{Role: RoleTool, ToolCallID: "c1", Content: "r1"},
131+
{Role: RoleTool, ToolCallID: "c2", Content: "r2"},
132+
{Role: RoleUser, Content: "follow up"},
133+
}
134+
135+
systemBlocks, msgs := p.buildMessages(in)
136+
137+
if len(systemBlocks) != 1 || systemBlocks[0].Text != "you are a bot" {
138+
t.Fatalf("system blocks = %+v, want single 'you are a bot'", systemBlocks)
139+
}
140+
// 期望序列:user(q) / assistant(tool_use) / user(2×tool_result 合并) / user(follow up)
141+
if len(msgs) != 4 {
142+
t.Fatalf("got %d messages, want 4: %+v", len(msgs), msgs)
143+
}
144+
if msgs[0].Role != anthropic.MessageParamRoleUser || msgs[1].Role != anthropic.MessageParamRoleAssistant {
145+
t.Fatalf("unexpected leading roles: %v / %v", msgs[0].Role, msgs[1].Role)
146+
}
147+
// 第三条是合并后的 tool_result,应含 2 个 tool_result block。
148+
merged := msgs[2]
149+
if merged.Role != anthropic.MessageParamRoleUser {
150+
t.Fatalf("merged tool results should be a user message, got role %v", merged.Role)
151+
}
152+
if len(merged.Content) != 2 {
153+
t.Fatalf("merged message should have 2 tool_result blocks, got %d", len(merged.Content))
154+
}
155+
for i, b := range merged.Content {
156+
if b.OfToolResult == nil {
157+
t.Fatalf("merged block %d is not a tool_result: %+v", i, b)
158+
}
159+
}
160+
}
161+
162+
// userBlocks:无内容无图时兜底成一个(空)文本块,避免空消息;Base64 与 URL 各走对应 image source。
163+
func TestAnthropicUserBlocks(t *testing.T) {
164+
// 空消息兜底。
165+
empty := userBlocks(Message{Role: RoleUser})
166+
if len(empty) != 1 || empty[0].OfText == nil {
167+
t.Fatalf("empty user message should fall back to a single text block, got %+v", empty)
168+
}
169+
170+
// 文本 + base64 图 + url 图。
171+
blocks := userBlocks(Message{
172+
Role: RoleUser,
173+
Content: "txt",
174+
Images: []ImagePart{
175+
{MediaType: "image/png", Base64: "abc"},
176+
{URL: "https://example.com/y.png"},
177+
},
178+
})
179+
if len(blocks) != 3 {
180+
t.Fatalf("got %d blocks, want 3 (text + 2 images)", len(blocks))
181+
}
182+
if blocks[0].OfText == nil || blocks[0].OfText.Text != "txt" {
183+
t.Fatalf("first block should be text 'txt', got %+v", blocks[0])
184+
}
185+
if blocks[1].OfImage == nil || blocks[2].OfImage == nil {
186+
t.Fatalf("image blocks not produced: %+v", blocks)
187+
}
188+
}

0 commit comments

Comments
 (0)