package service import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "xly/server/internal/config" "xly/server/internal/model" ) var ErrAIUnavailable = errors.New("ai service is not configured") const divinationSystemPrompt = `你是“四课小六壬”解卦助手,负责把盘面结果翻译成现实语境下可执行的中文建议。 你的工作边界: 1. 只基于用户提供的盘面数据解读,不臆造不存在的课位、关系或额外神煞。 2. 结果必须写成趋势判断、阻力判断和行动建议,不得写成绝对预言。 3. 语言要直白、稳重、可落实,不故作神秘,不堆砌玄学术语。 4. 优先解释“为什么这样判断”,尤其要结合四宫次第、终宫落点与五行生克。 内置断法: 1. 四课顺序固定为年宫 → 月宫 → 日宫 → 时宫。 2. 年宫主大环境、外部周期、权力方态度。 3. 月宫主当下阶段的资源、氛围、预算、团队状态。 4. 日宫主执行面、沟通、临场表现、眼前动作。 5. 时宫主结果落点,成败判断以时宫为主。 6. 要结合四宫之间的生、克、比和链路判断助力与阻力是否顺势串联。 7. 前端给出的“本地规则总断”只能作为参考线索,不能机械复述,要重新组织成完整分析。 输出规则: 1. 必须使用 Markdown。 2. 必须严格按下面结构输出,不要新增无关标题,不要输出代码块: ### 总断 先用 1 句总结走势,再用 1 段解释核心判断。 ### 四宫详解与五行生克分析 #### 年宫 #### 月宫 #### 日宫 #### 时宫 四个小节都要解释:该宫位含义、当前盘面显示的倾向、它对结果的作用。 这一大节最后补 1 段,专门总结生克链路是顺还是逆、哪里在助推、哪里在掣肘。 ### 趋吉次第 输出 3 到 4 条有先后顺序的编号列表。每条都必须是可执行动作。 ### 避忌要点 输出 2 到 3 条无序列表。每条都写清具体风险。 ### 结语 最后单独写一句:仅供民俗文化参考,不替代现实决策。 格式细则: 1. 标题只用 ### 和 ####。 2. 列表只用“1.”或“-”。 3. 不要输出表格。 4. 不要用“根据你提供的信息我认为”这种空话开头。 5. 不要在标题前后添加多余的星号。` // AIService 负责代理上游 AI 服务。 // 该服务只暴露统一的解卦入口,不向前端泄露上游鉴权细节。 type AIService struct { config config.Config httpClient *http.Client } type upstreamChatRequest struct { Model string `json:"model"` Temperature float64 `json:"temperature"` Messages []upstreamChatMessage `json:"messages"` EnableThinking bool `json:"enable_thinking"` } type upstreamChatMessage struct { Role string `json:"role"` Content string `json:"content"` } type upstreamChatResponse struct { Choices []struct { Message struct { Content any `json:"content"` } `json:"message"` } `json:"choices"` Error *struct { Message string `json:"message"` } `json:"error"` } // NewAIService 创建 AI 代理服务。 func NewAIService(cfg config.Config) *AIService { return &AIService{ config: cfg, httpClient: &http.Client{ Timeout: cfg.AITimeout, }, } } // Status 返回前端可见的 AI 服务状态。 func (s *AIService) Status() model.AIStatusResponse { if !s.config.AIEnabled() { return model.AIStatusResponse{ Enabled: false, ProviderName: s.config.ProviderName, Model: emptyAs(s.config.AIModel, "未配置"), Mode: "server-proxy", Message: "后端已启动,但 AI_BASE_URL / AI_MODEL / AI_API_KEY 尚未完整配置。", } } return model.AIStatusResponse{ Enabled: true, ProviderName: s.config.ProviderName, Model: s.config.AIModel, Mode: "server-proxy", Message: "后端将代表前端调用上游 AI 服务。", } } // Interpret 调用上游 AI 服务生成解卦文本。 func (s *AIService) Interpret(ctx context.Context, input model.InterpretRequest) (string, error) { if !s.config.AIEnabled() { return "", ErrAIUnavailable } payload, err := json.Marshal(upstreamChatRequest{ Model: s.config.AIModel, Temperature: 0.8, EnableThinking: false, Messages: []upstreamChatMessage{ { Role: "system", Content: divinationSystemPrompt, }, { Role: "user", Content: buildUserPrompt(input), }, }, }) if err != nil { return "", fmt.Errorf("marshal upstream request: %w", err) } request, err := http.NewRequestWithContext( ctx, http.MethodPost, s.config.AIBaseURL+s.config.AIChatPath, bytes.NewReader(payload), ) if err != nil { return "", fmt.Errorf("build upstream request: %w", err) } request.Header.Set("Content-Type", "application/json") request.Header.Set("Authorization", "Bearer "+s.config.AIAPIKey) response, err := s.httpClient.Do(request) if err != nil { return "", fmt.Errorf("request upstream ai: %w", err) } defer response.Body.Close() body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20)) if err != nil { return "", fmt.Errorf("read upstream response: %w", err) } var decoded upstreamChatResponse if err := json.Unmarshal(body, &decoded); err != nil { return "", fmt.Errorf("decode upstream response: %w", err) } if response.StatusCode >= http.StatusBadRequest { message := "上游 AI 服务返回错误。" if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" { message = decoded.Error.Message } return "", errors.New(message) } if len(decoded.Choices) == 0 { return "", errors.New("上游 AI 服务未返回可用结果") } content := extractContent(decoded.Choices[0].Message.Content) if strings.TrimSpace(content) == "" { return "", errors.New("上游 AI 服务返回成功,但内容为空") } return content, nil } func buildUserPrompt(input model.InterpretRequest) string { slotLines := make([]string, 0, len(input.Slots)) for _, slot := range input.Slots { slotLines = append(slotLines, fmt.Sprintf( "%s:取值=%s(%d),来源=%s,来源说明=%s,落宫=%s,五行=%s,宫义=%s%s,职责=%s,干支参考=%s", slot.Label, slot.Token, slot.Value, slot.Source, slot.SourceNote, slot.Palace, slot.Element, slot.Theme, slot.Detail, slot.SlotExplanation, emptyAs(slot.GanZhi, "-"), )) } relationLines := make([]string, 0, len(input.Relations)) for _, relation := range input.Relations { relationLines = append(relationLines, fmt.Sprintf("- %s:%s", relation.Kind, relation.Summary)) } if len(relationLines) == 0 { relationLines = append(relationLines, "- 无可用链路") } return fmt.Sprintf(`问题:%s 起课方式:%s(%s) 公历时间:%s 农历时间:%s 起课说明:%s 终宫:%s · 五行%s 四宫盘面: %s 生克链路: %s 本地规则总断参考: - 总评:%s - 分数:%d - 摘要:%s - 事项类型:%s - 链路总结:%s - 趋吉建议:%s - 风险提示:%s`, input.Question, input.ModeLabel, input.Mode, input.SolarLabel, input.LunarLabel, input.MethodNote, input.FinalPalace, input.FinalElement, strings.Join(slotLines, "\n"), strings.Join(relationLines, "\n"), input.LocalInterpretation.Verdict, input.LocalInterpretation.Score, input.LocalInterpretation.Summary, input.LocalInterpretation.DomainLabel, emptyAs(input.LocalInterpretation.ChainSummary, "无"), strings.Join(input.LocalInterpretation.ActionAdvice, ";"), strings.Join(input.LocalInterpretation.RiskAdvice, ";"), ) } func extractContent(content any) string { switch value := content.(type) { case string: return strings.TrimSpace(value) case []any: parts := make([]string, 0, len(value)) for _, item := range value { part, ok := item.(map[string]any) if !ok { continue } text, _ := part["text"].(string) if strings.TrimSpace(text) != "" { parts = append(parts, strings.TrimSpace(text)) } } return strings.Join(parts, "\n") default: return "" } } func emptyAs(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value }