package main // ai_tasks.go 一句话生成多条待办/工单:调用 AI 把自然语言拆解成结构化草稿, // 前端预览确认后再逐条走 SaveTodo / SaveTicket 正常落库。 import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "view/service/ai" ) // AITaskDraft 是 AI 生成的一条草稿(todo 与 ticket 共用,前端按 kind 取字段)。 type AITaskDraft struct { Kind string `json:"kind"` // todo | ticket Title string `json:"title"` Content string `json:"content"` // todo 备注 Description string `json:"description"` // ticket 描述 Type string `json:"type"` // ticket: feature | bug | task | improvement Priority string `json:"priority"` // low | medium | high StartAt string `json:"startAt"` // ticket: YYYY-MM-DD DueAt string `json:"dueAt"` // todo: YYYY-MM-DDTHH:MM;ticket: YYYY-MM-DD } // AIGenerateTasks 把一句话拆解成多条待办或工单草稿(不落库,由前端确认后保存)。 func (a *App) AIGenerateTasks(kind, text string) ([]AITaskDraft, error) { if e := a.ready(); e != nil { return nil, e } if kind != "todo" && kind != "ticket" { return nil, errors.New("BAD_REQUEST") } text = strings.TrimSpace(text) if text == "" { return nil, errors.New("AI_EMPTY_MESSAGE") } provider, e := a.aiProvider() if e != nil { return nil, e } if e := a.checkAIPolicy(); e != nil { return nil, e } locale := "zh-CN" if st, se := a.store.Settings(); se == nil && st.Locale != "" { locale = st.Locale } msgs := []ai.Message{ {Role: "system", Content: aiTaskPrompt(kind, locale)}, {Role: "user", Content: text}, } ctx, cancel := context.WithTimeout(a.ctx, 2*time.Minute) defer cancel() stream, e := provider.ChatStream(ctx, msgs) if e != nil { a.store.Log("error", "AI", "AI 任务生成请求失败", e.Error()) return nil, e } var sb strings.Builder var usage *ai.Usage for chunk := range stream { if chunk.Usage != nil { usage = chunk.Usage } if chunk.Err != nil { a.store.Log("error", "AI", "AI 任务生成失败", chunk.Err.Error()) return nil, chunk.Err } sb.WriteString(chunk.Content) } a.recordAIUsage(provider.Name(), "generate:"+kind, usage, msgs, sb.String()) drafts, pe := parseAITaskDrafts(kind, sb.String()) if pe != nil { a.store.Log("warning", "AI", "AI 任务生成解析失败", sb.String()) return nil, pe } a.store.Log("info", "AI", "AI 任务生成完成", fmt.Sprintf("kind=%s count=%d", kind, len(drafts))) return drafts, nil } func aiTaskPrompt(kind, locale string) string { head := aiCalendarContext(locale) if kind == "ticket" { return head + `你是研发工单拆解助手。把用户输入拆成 1~10 条工单。 只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素: {"title":"简短标题(<=40字)","description":"补充细节,可为空","type":"feature|bug|task|improvement","priority":"low|medium|high","startAt":"YYYY-MM-DD","dueAt":"YYYY-MM-DD"} 日期规则(必须遵守): 1. 按用户输入的语言理解相对日期(中文:今天/明天/后天/大后天/本周X/下周X/这周五/下周一/月底/下个月;英文:today/tomorrow/this Friday/next Monday/end of week 等),对照上面日历换成具体 YYYY-MM-DD。 2. 用户说的不一定是今天:若整段话指向某一天或某一周,所有条目的 startAt/dueAt 都落在那个时间范围内,不要一律填今天。 3. 一句话里提到多天(如“周三做A,周五做B”“这周每天…”),每条工单各自写对应的 startAt 和 dueAt,不要全部同一天。 4. 每条都必须写 dueAt。用户给了截止就用;只给了开始日则按工作量估 1~5 天;完全没提日期则 startAt=今天、dueAt 按工作量估。 5. dueAt 不得早于 startAt。标题语言与用户输入一致。 6. 用户提到节假日/黄金周/放假/调休(国庆、十一、春节、过年、中秋、端午、元旦、五一、清明、圣诞、感恩节等),必须对照上面节假日表换成具体 YYYY-MM-DD,禁止自己换算农历。「X前」= 节日前一天;「X期间」= 表中放假区间(没有则用当天);「X后」= 假期结束后一天。` } return head + `你是待办事项拆解助手。把用户输入拆成 1~10 条待办。 只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素: {"title":"简短标题(<=40字)","content":"补充细节,可为空","priority":"low|medium|high","dueAt":"YYYY-MM-DDTHH:MM"} 日期规则(必须遵守): 1. 按用户输入的语言理解相对日期和时间(中文:今天/明天/后天/本周X/下周X/上午/下午/晚上/点钟;英文:today/tomorrow/this Friday/next week/afternoon 等),对照上面日历换成具体 YYYY-MM-DDTHH:MM。 2. 用户说的不一定是今天:若整段话指向某一天或某一周,所有条目的 dueAt 都落在那个时间,不要一律填今天。 3. 一句话里提到多天(如“周一买菜,周三交报告”),每条待办各自写对应的 dueAt。 4. 每条都必须写 dueAt,不要留空。用户给了时刻就用;只给了日期则:上午→10:00、下午→18:00、晚上→21:00、未提时段→18:00。完全没提日期则按紧急度估:高优今天 18:00,中优明天 18:00,低优 3 天后 18:00。 5. 标题语言与用户输入一致。 6. 用户提到节假日/黄金周/放假(国庆、十一、春节、过年、中秋、端午、元旦、五一、清明、圣诞、感恩节等),必须对照上面节假日表换成具体日期,禁止自己换算农历。「X前」= 节日前一天 18:00;「X期间」落在放假区间;「X后」从假期结束后一天起。` } // aiCalendarContext 给模型一份「今天 + 本周/下周对照表」,避免相对日期猜错,也不默认全是今天。 func aiCalendarContext(locale string) string { now := time.Now() en := strings.HasPrefix(strings.ToLower(locale), "en") wdCN := []string{"日", "一", "二", "三", "四", "五", "六"} wdEN := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} weekday := func(t time.Time) string { if en { return wdEN[int(t.Weekday())] } return "星期" + wdCN[int(t.Weekday())] } // 本周一:Go Weekday 周日=0 offset := int(now.Weekday()) if offset == 0 { offset = 7 } monday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, 1-offset) var b strings.Builder if en { fmt.Fprintf(&b, "Today is %s (%s), current time %s. Timezone is the user's local time.\n", now.Format("2006-01-02"), weekday(now), now.Format("15:04")) b.WriteString("This week:\n") } else { fmt.Fprintf(&b, "今天是 %s(%s),当前时间 %s。时区按用户本机。\n", now.Format("2006-01-02"), weekday(now), now.Format("15:04")) b.WriteString("本周对照:\n") } for i := 0; i < 7; i++ { d := monday.AddDate(0, 0, i) mark := "" if d.Format("2006-01-02") == now.Format("2006-01-02") { if en { mark = " ← today" } else { mark = " ← 今天" } } fmt.Fprintf(&b, "- %s %s%s\n", weekday(d), d.Format("2006-01-02"), mark) } if en { b.WriteString("Next week:\n") } else { b.WriteString("下周对照:\n") } for i := 0; i < 7; i++ { d := monday.AddDate(0, 0, 7+i) fmt.Fprintf(&b, "- %s %s\n", weekday(d), d.Format("2006-01-02")) } monthEnd := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()) nextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location()) if en { fmt.Fprintf(&b, "End of this month: %s. First day of next month: %s.\n", monthEnd.Format("2006-01-02"), nextMonth.Format("2006-01-02")) b.WriteString("Convert relative phrases using this calendar. Do not default every item to today unless the user said today or gave no date at all.\n") } else { fmt.Fprintf(&b, "本月最后一天:%s。下月 1 日:%s。\n", monthEnd.Format("2006-01-02"), nextMonth.Format("2006-01-02")) b.WriteString("相对日期必须对照上面日历换算。用户没说今天时,不要把每条都填成今天。\n") } b.WriteString(formatHolidayBlock(now, en)) return b.String() } // parseAITaskDrafts 从模型输出中提取 JSON 数组并规范化字段。 func parseAITaskDrafts(kind, raw string) ([]AITaskDraft, error) { raw = stripFence(strings.TrimSpace(raw)) start, end := strings.Index(raw, "["), strings.LastIndex(raw, "]") if start < 0 || end <= start { return nil, errors.New("AI_BAD_RESPONSE") } var list []AITaskDraft if json.Unmarshal([]byte(raw[start:end+1]), &list) != nil { return nil, errors.New("AI_BAD_RESPONSE") } today := time.Now().Format("2006-01-02") out := make([]AITaskDraft, 0, len(list)) for _, d := range list { d.Kind = kind d.Title = strings.TrimSpace(d.Title) if d.Title == "" { continue } if r := []rune(d.Title); len(r) > 120 { d.Title = string(r[:120]) } if d.Priority != "low" && d.Priority != "medium" && d.Priority != "high" { d.Priority = "medium" } if kind == "ticket" { switch d.Type { case "feature", "bug", "task", "improvement": default: d.Type = "task" } d.Content = "" // 优先保留模型按用户语义写出的日期;缺了才补,避免把「下周三」整批改成今天。 d.StartAt = dateOnly(d.StartAt) d.DueAt = dateOnly(d.DueAt) if d.StartAt == "" && d.DueAt != "" { d.StartAt = d.DueAt } if d.StartAt == "" { d.StartAt = today } if d.DueAt == "" { if t, e := time.Parse("2006-01-02", d.StartAt); e == nil { d.DueAt = t.AddDate(0, 0, 2).Format("2006-01-02") } else { d.DueAt = time.Now().AddDate(0, 0, 2).Format("2006-01-02") } } if d.DueAt < d.StartAt { d.DueAt = d.StartAt } } else { d.Type, d.Description, d.StartAt = "", "", "" d.DueAt = normalizeTodoDue(d.DueAt) if d.DueAt == "" { // 待办也必须有截止日期,缺省按优先级估。 days := 1 if d.Priority == "high" { days = 0 } else if d.Priority == "low" { days = 3 } d.DueAt = time.Now().AddDate(0, 0, days).Format("2006-01-02") + "T18:00" } } out = append(out, d) if len(out) >= 20 { break } } if len(out) == 0 { return nil, errors.New("AI_EMPTY_RESULT") } return out, nil } func validDateStr(s string) bool { return dateOnly(s) != "" } // dateOnly 从 YYYY-MM-DD 或带时间的写法里抽出日期;解析失败返回空。 func dateOnly(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } if i := strings.IndexAny(s, "T "); i > 0 { s = s[:i] } if _, e := time.Parse("2006-01-02", s); e == nil { return s } return "" } // normalizeTodoDue 接受常见日期/时间写法,统一成 datetime-local 用的 YYYY-MM-DDTHH:MM。 func normalizeTodoDue(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } s = strings.ReplaceAll(s, " ", "T") for _, layout := range []string{ "2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339, } { if t, e := time.Parse(layout, s); e == nil { return t.Format("2006-01-02T15:04") } } if validDateStr(s) { return s + "T18:00" } if i := strings.Index(s, "T"); i == 10 && validDateStr(s[:10]) { return s[:10] + "T18:00" } return "" }