Version: 0.5.8.dev.260315

♻️ refactor(agent): 拆分 agentsvc,并增强 quicknote/outbox 注释与可维护性

- 📦 将 Agent 服务实现从 `service` 根目录迁移到 `service/agentsvc`,包含 `agent.go`、`agent_quick_note.go` 及相关测试
- 🔌 新增 service 层兼容桥接 `agent_bridge.go`,保持 `service.NewAgentService` 与 `*service.AgentService` 现有调用方式不变
- 📝 为 `quicknote` 补充高密度中文步骤化注释,覆盖 `graph` / `runner` / `nodes` / `tool` / `state` / `prompt`,明确职责边界、分支条件、重试与兜底策略
- 🧭 为 `infra/outbox` 与 service agent 链路补充详细中文注释,覆盖状态机流转、幂等处理、失败回写与异步持久化语义
-  统一格式化相关文件,并通过全量后端测试:`go test ./...`

📝 chore(docs): 更新 AGENTS.md 注释强制规范

- 📚 追加“注释规范(强制)”与“注释风格示例”
- ✍️ 明确复杂逻辑必须使用步骤化注释、跨文件调用需写调用目的、注释需同步维护
This commit is contained in:
Losita
2026-03-15 18:08:33 +08:00
parent c689af56c8
commit 7603a7561a
22 changed files with 1009 additions and 429 deletions

View File

@@ -69,9 +69,11 @@ type QuickNoteToolDeps struct {
}
func (d QuickNoteToolDeps) validate() error {
// 1. ResolveUserID 为空会导致工具无法绑定当前用户,必须提前失败。
if d.ResolveUserID == nil {
return errors.New("quick note tool deps: ResolveUserID is nil")
}
// 2. CreateTask 为空说明没有真实写库实现,工具无法完成核心职责。
if d.CreateTask == nil {
return errors.New("quick note tool deps: CreateTask is nil")
}
@@ -128,18 +130,23 @@ type QuickNoteCreateTaskToolOutput struct {
// BuildQuickNoteToolBundle 构建“AI随口记”工具包。
// 这是 agent 目录给上层编排层chain/graph/react提供的统一入口。
func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*QuickNoteToolBundle, error) {
// 1. 启动期做依赖校验,尽早暴露 wiring 问题,避免运行时才 panic。
if err := deps.validate(); err != nil {
return nil, err
}
// 2. 通过 InferTool 把 Go 函数声明成“模型可调用工具”。
// 该闭包函数是工具的真实执行体,后续所有参数校验都在这里兜底。
createTaskTool, err := toolutils.InferTool(
ToolNameQuickNoteCreateTask,
ToolDescQuickNoteCreateTask,
func(ctx context.Context, input *QuickNoteCreateTaskToolInput) (*QuickNoteCreateTaskToolOutput, error) {
// 2.1 防御式检查:工具调用参数不能为 nil。
if input == nil {
return nil, errors.New("工具参数不能为空")
}
// 2.2 标题与优先级是写库硬条件,必须先校验。
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("title 不能为空")
@@ -156,6 +163,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
return nil, err
}
// 2.3 user_id 一律来自鉴权上下文,不信任模型侧入参,防止越权写别人的任务。
userID, err := deps.ResolveUserID(ctx)
if err != nil {
return nil, fmt.Errorf("解析用户身份失败: %w", err)
@@ -164,6 +172,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
return nil, fmt.Errorf("非法 user_id=%d", userID)
}
// 2.4 走业务层写库。
result, err := deps.CreateTask(ctx, QuickNoteCreateTaskRequest{
UserID: userID,
Title: title,
@@ -177,6 +186,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
return nil, errors.New("写入任务后返回结果异常")
}
// 2.5 结果归一化:优先使用业务层返回值,其次回退到入参,保证输出稳定可读。
finalTitle := title
if strings.TrimSpace(result.Title) != "" {
finalTitle = strings.TrimSpace(result.Title)
@@ -187,6 +197,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
finalPriority = result.PriorityGroup
}
// 2.6 截止时间输出统一为 RFC3339便于跨系统传输与调试。
deadlineStr := ""
if result.DeadlineAt != nil {
deadlineStr = result.DeadlineAt.In(quickNoteLocation()).Format(time.RFC3339)
@@ -194,6 +205,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
deadlineStr = deadline.In(quickNoteLocation()).Format(time.RFC3339)
}
// 2.7 组装给模型的结构化结果,包含可直接面向用户的 message 草稿。
return &QuickNoteCreateTaskToolOutput{
TaskID: result.TaskID,
Title: finalTitle,
@@ -208,6 +220,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
return nil, fmt.Errorf("构建随口记工具失败: %w", err)
}
// 3. Tools 给执行节点使用ToolInfos 给模型注册 schema 使用,二者都要返回。
tools := []tool.BaseTool{createTaskTool}
infos, err := collectToolInfos(ctx, tools)
if err != nil {
@@ -221,6 +234,7 @@ func BuildQuickNoteToolBundle(ctx context.Context, deps QuickNoteToolDeps) (*Qui
}
func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.ToolInfo, error) {
// 按工具列表顺序提取 ToolInfo确保“tools[idx] <-> infos[idx]”一一对应。
infos := make([]*schema.ToolInfo, 0, len(tools))
for _, t := range tools {
info, err := t.Info(ctx)
@@ -235,16 +249,20 @@ func collectToolInfos(ctx context.Context, tools []tool.BaseTool) ([]*schema.Too
// parseOptionalDeadline 解析工具输入中的可选截止时间。
// 该入口用于“工具参数强校验”:只要调用方给了非空 deadline_at就必须能被解析。
func parseOptionalDeadline(raw string) (*time.Time, error) {
// 1. 先做标点与空白归一化,避免中文输入噪声影响解析。
value := normalizeDeadlineInput(raw)
if value == "" {
// 2. 空字符串合法,表示任务无截止时间。
return nil, nil
}
// 3. 统一按“严格模式”解析:给了时间就必须成功解析。
deadline, hasHint, err := parseOptionalDeadlineFromText(value, quickNoteNowToMinute())
if err != nil {
return nil, err
}
if deadline == nil {
// 4. 区分“无时间线索”和“有线索但不支持”,返回更准确错误信息。
if !hasHint {
return nil, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
@@ -256,6 +274,7 @@ func parseOptionalDeadline(raw string) (*time.Time, error) {
// parseOptionalDeadlineWithNow 在给定时间基准下解析 deadline。
// 该函数保持“严格模式”:非空字符串无法解析时会直接返回 error。
func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error) {
// 场景:模型已给出 deadline_at需要基于同一 requestNow 再次硬校验。
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, nil
@@ -277,6 +296,7 @@ func parseOptionalDeadlineWithNow(raw string, now time.Time) (*time.Time, error)
// - hasHint=false 且 err=nil文本里没有明显时间线索应视为“用户没给时间”
// - hasHint=true 且 err!=nil用户给了时间但格式非法应提示用户修正不应落库。
func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time, bool, error) {
// 场景:解析用户原始句子时,允许“没给时间”,但不允许“给了错误时间却静默通过”。
value := normalizeDeadlineInput(raw)
if value == "" {
return nil, false, nil
@@ -285,8 +305,10 @@ func parseOptionalDeadlineFromUserInput(raw string, now time.Time) (*time.Time,
deadline, hasHint, err := parseOptionalDeadlineFromText(value, now)
if err != nil {
if hasHint {
// 有时间线索 + 解析失败:上层应明确提示用户改时间格式。
return nil, true, err
}
// 无明显时间线索:按“未提供时间”处理。
return nil, false, nil
}
if deadline == nil {
@@ -308,14 +330,17 @@ func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, boo
return nil, false, nil
}
// 1. 统一时区与时间基准,保证相对时间可重复计算。
loc := quickNoteLocation()
now = now.In(loc)
hasHint := hasDeadlineHint(value)
// 2. 先尝试绝对时间(优先级更高,歧义更小)。
if abs, ok := tryParseAbsoluteDeadline(value, loc); ok {
return abs, true, nil
}
// 3. 再尝试相对时间(明天/下周一/今晚)。
if rel, recognized, err := tryParseRelativeDeadline(value, now, loc); recognized {
if err != nil {
return nil, true, err
@@ -323,6 +348,7 @@ func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, boo
return rel, true, nil
}
// 4. 到这里仍失败时,根据 hasHint 决定返回“软失败”还是“硬失败”。
if hasHint {
return nil, true, fmt.Errorf("deadline_at 格式不支持: %s", value)
}
@@ -331,10 +357,12 @@ func parseOptionalDeadlineFromText(value string, now time.Time) (*time.Time, boo
// normalizeDeadlineInput 把中文标点和空白先归一化,降低格式解析的噪声。
func normalizeDeadlineInput(raw string) string {
// 先 trim避免纯空格输入影响后续逻辑。
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
// 将中文标点统一成英文形态,降低正则和 layout 解析复杂度。
replacer := strings.NewReplacer(
"", ":",
"", ",",
@@ -349,6 +377,7 @@ func normalizeDeadlineInput(raw string) string {
// 1) 用户根本没给时间(允许 deadline 为空);
// 2) 用户给了时间但写错(必须提示修正,不能静默写 NULL
func hasDeadlineHint(value string) bool {
// 1. 先用结构化正则快速判断(时间格式、日期格式、周几格式)。
if quickNoteClockHMRegex.MatchString(value) ||
quickNoteClockCNRegex.MatchString(value) ||
quickNoteYMDRegex.MatchString(value) ||
@@ -357,6 +386,7 @@ func hasDeadlineHint(value string) bool {
quickNoteWeekdayRegex.MatchString(value) {
return true
}
// 2. 再用词元判断“明天/今晚”等语义线索。
for _, token := range quickNoteRelativeTokens {
if strings.Contains(value, token) {
return true
@@ -368,6 +398,7 @@ func hasDeadlineHint(value string) bool {
// tryParseAbsoluteDeadline 尝试按绝对时间格式解析。
// 若只提供日期(无时分),默认归一到当天 23:59表示“当日截止”。
func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, bool) {
// 逐个 layout 尝试,命中即返回。
for _, layout := range quickNoteDeadlineLayouts {
var (
t time.Time
@@ -385,9 +416,11 @@ func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, boo
continue
}
// Date-only 输入(例如 2026-03-20默认补到 23:59。
if _, dateOnly := quickNoteDateOnlyLayouts[layout]; dateOnly {
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 0, 0, loc)
} else {
// 非 date-only 则统一清零秒级,保持分钟粒度一致。
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc)
}
return &t, true
@@ -400,11 +433,13 @@ func tryParseAbsoluteDeadline(value string, loc *time.Location) (*time.Time, boo
// - 明天交报告(默认 23:59
// - 下周一上午9点开会解析为下周一 09:00
func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (*time.Time, bool, error) {
// 1. 先确定“哪一天”。
baseDate, recognized := inferBaseDate(value, now, loc)
if !recognized {
return nil, false, nil
}
// 2. 再解析“几点几分”,若缺失则按语义默认时刻兜底。
hour, minute, hasExplicitClock, err := extractClock(value)
if err != nil {
return nil, true, err
@@ -424,6 +459,7 @@ func tryParseRelativeDeadline(value string, now time.Time, loc *time.Location) (
// 3) 周几表达(本周/下周);
// 4) 明天/后天/今晚等相对词。
func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time, bool) {
// 1) yyyy年MM月dd日
if matched := quickNoteYMDRegex.FindStringSubmatch(value); len(matched) == 4 {
year, _ := strconv.Atoi(matched[1])
month, _ := strconv.Atoi(matched[2])
@@ -433,6 +469,7 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
}
}
// 2) MM月dd日自动推断年份若今年已过则滚到明年
if matched := quickNoteMDRegex.FindStringSubmatch(value); len(matched) == 3 {
month, _ := strconv.Atoi(matched[1])
day, _ := strconv.Atoi(matched[2])
@@ -451,6 +488,7 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
return candidate, true
}
// 3) 本周/下周 + 周几
if matched := quickNoteWeekdayRegex.FindStringSubmatch(value); len(matched) == 3 {
prefix := matched[1]
target, ok := toWeekday(matched[2])
@@ -459,6 +497,7 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
}
}
// 4) 今天/明天/后天/大后天/昨天等相对词
today := startOfDay(now)
switch {
case strings.Contains(value, "大后天"):
@@ -481,10 +520,12 @@ func inferBaseDate(value string, now time.Time, loc *time.Location) (time.Time,
// - 24h 表达18:30
// - 中文表达3点、3点半、3点20分
func extractClock(value string) (int, int, bool, error) {
// hour/minute 最终会用于 time.Date需要先做范围约束。
hour := 0
minute := 0
hasClock := false
// 1) 24 小时制18:30
if matched := quickNoteClockHMRegex.FindStringSubmatch(value); len(matched) == 3 {
h, errH := strconv.Atoi(matched[1])
m, errM := strconv.Atoi(matched[2])
@@ -495,6 +536,7 @@ func extractClock(value string) (int, int, bool, error) {
minute = m
hasClock = true
} else if matched := quickNoteClockCNRegex.FindStringSubmatch(value); len(matched) >= 2 {
// 2) 中文时刻3点 / 3点半 / 3点20分
h, errH := strconv.Atoi(matched[1])
if errH != nil {
return 0, 0, true, fmt.Errorf("deadline_at 时间解析失败: %s", value)
@@ -516,9 +558,11 @@ func extractClock(value string) (int, int, bool, error) {
}
if !hasClock {
// 没有显式时刻并不是错误,交给默认时刻策略处理。
return 0, 0, false, nil
}
// 3) 根据“下午/晚上/中午/凌晨”等语义修正 12/24 小时制。
if isPMHint(value) && hour < 12 {
hour += 12
}
@@ -537,6 +581,7 @@ func extractClock(value string) (int, int, bool, error) {
// defaultClockByHint 当文本只给了“日期/相对日”但没给具体时刻时,按语义兜底。
func defaultClockByHint(value string) (int, int) {
// 没有明确时刻时按中文语义设置一个“可解释的默认值”。
switch {
case strings.Contains(value, "凌晨"):
return 1, 0
@@ -555,19 +600,23 @@ func defaultClockByHint(value string) (int, int) {
}
func isPMHint(value string) bool {
// 下午/晚上/傍晚通常应映射到 12:00 之后。
return strings.Contains(value, "下午") || strings.Contains(value, "晚上") || strings.Contains(value, "今晚") || strings.Contains(value, "傍晚")
}
func isNoonHint(value string) bool {
// “中午 1 点”这类表达通常是 13:00 而非 01:00。
return strings.Contains(value, "中午")
}
func startOfDay(t time.Time) time.Time {
// 保留原时区,只把时分秒归零。
loc := t.Location()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
func isValidDate(year, month, day int) bool {
// 先做快速范围筛,再用 time.Date 回填校验闰月闰年和越界日期。
if month < 1 || month > 12 || day < 1 || day > 31 {
return false
}
@@ -576,6 +625,7 @@ func isValidDate(year, month, day int) bool {
}
func toWeekday(chinese string) (time.Weekday, bool) {
// 把中文周几映射到 Go 的 Weekday 枚举。
switch chinese {
case "一":
return time.Monday, true
@@ -598,12 +648,14 @@ func toWeekday(chinese string) (time.Weekday, bool) {
// resolveWeekdayDate 根据“本周/下周 + 周几”换算目标日期。
func resolveWeekdayDate(now time.Time, prefix string, target time.Weekday) time.Time {
// 1. 先定位本周周一。
today := startOfDay(now)
weekdayOffset := (int(today.Weekday()) + 6) % 7
weekStart := today.AddDate(0, 0, -weekdayOffset)
targetOffset := (int(target) + 6) % 7
candidateThisWeek := weekStart.AddDate(0, 0, targetOffset)
// 2. 再根据“本周/下周/无前缀”选择最终日期。
switch {
case strings.HasPrefix(prefix, "下"):
return candidateThisWeek.AddDate(0, 0, 7)