Files
code-utils/ai_tasks.go
李琦 e71ce451ff 模块介绍 / 今日规划 / 下班日报 / 团队摘要 生成成功后没有上报,也几乎不写「AI」日志。
流结束时的 usage 用了非阻塞发送,通道一满就被丢掉;很多服务商默认还不在流里带 usage。
现在每次真正打到模型的调用都会:

在本地日志(分类 AI,消息 AI 调用完成)写下 scene / prompt / completion / cached
登录后同步上报到后台日汇总
2026-08-17 14:00:47 +08:00

290 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:MMticket: 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 ""
}