Files

297 lines
8.1 KiB
Go
Raw Permalink 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 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
}