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 } msgs := []ai.Message{ {Role: "system", Content: aiTaskPrompt(kind)}, {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) } if usage != nil { go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated) } 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 string) string { now := time.Now() weekdays := []string{"日", "一", "二", "三", "四", "五", "六"} head := fmt.Sprintf("今天是 %s(星期%s),当前时间 %s。\n", now.Format("2006-01-02"), weekdays[int(now.Weekday())], now.Format("15:04")) 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"} 规则:标题用与用户输入相同的语言;startAt 默认今天;dueAt 不得早于 startAt,用户未提及工期时按任务量合理估算(1~7 天);能从用户话中推断出的日期(如“周五前”“下周”)必须转换为具体日期。` } return head + `你是待办事项拆解助手。把用户的一句话拆成 1~10 条待办。 只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素: {"title":"简短标题(<=40字)","content":"补充细节,可为空","priority":"low|medium|high","dueAt":"YYYY-MM-DDTHH:MM 或空字符串"} 规则:标题用与用户输入相同的语言;只有用户明确或可合理推断截止时间时才填 dueAt(如“明天下午”→ 明天 18:00),否则留空字符串。` } // 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 = "" if !validDateStr(d.StartAt) { d.StartAt = today } if !validDateStr(d.DueAt) { d.DueAt = time.Now().AddDate(0, 0, 3).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) } 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 { _, e := time.Parse("2006-01-02", strings.TrimSpace(s)) return e == nil } // normalizeTodoDue 接受 YYYY-MM-DDTHH:MM 或纯日期(补 18:00),其余返回空。 func normalizeTodoDue(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } if _, e := time.Parse("2006-01-02T15:04", s); e == nil { return s } if validDateStr(s) { return s + "T18:00" } return "" }