package mapping import ( "context" "encoding/json" "fmt" "strings" "qctextbuilder/internal/domain" "qctextbuilder/internal/llmruntime" ) type SettingsReader interface { GetSettings(ctx context.Context) (*domain.AppSettings, error) } type ProviderAwareSuggestionGenerator struct { settings SettingsReader runtimeFactory *llmruntime.Factory } func NewProviderAwareSuggestionGenerator(settings SettingsReader, runtimeFactory *llmruntime.Factory) *ProviderAwareSuggestionGenerator { return &ProviderAwareSuggestionGenerator{ settings: settings, runtimeFactory: runtimeFactory, } } func (g *ProviderAwareSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) { if g == nil || g.settings == nil || g.runtimeFactory == nil { return SuggestionResult{}, fmt.Errorf("provider-aware generator is not configured") } settings, err := g.settings.GetSettings(ctx) if err != nil || settings == nil { return SuggestionResult{}, fmt.Errorf("llm settings are not available") } provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider) model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel) if strings.TrimSpace(model) == "" { return SuggestionResult{}, fmt.Errorf("no active model configured") } apiKey := apiKeyForProvider(provider, *settings) if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" { return SuggestionResult{}, fmt.Errorf("api key for provider %s is not configured", provider) } targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled) if len(targets) == 0 { return SuggestionResult{Suggestions: []Suggestion{}, ByFieldPath: map[string]Suggestion{}}, nil } allowed := make(map[string]SemanticSlotTarget, len(targets)) for _, target := range targets { allowed[target.FieldPath] = target } providerClient, err := g.runtimeFactory.ClientFor(provider) if err != nil { return SuggestionResult{}, err } systemPrompt, userPrompt := buildProviderPrompts(req, targets) raw, err := providerClient.Generate(ctx, llmruntime.Request{ Provider: provider, Model: model, BaseURL: strings.TrimSpace(settings.LLMBaseURL), APIKey: strings.TrimSpace(apiKey), SystemPrompt: systemPrompt, UserPrompt: userPrompt, }) if err != nil { return SuggestionResult{}, err } parsed, err := parseProviderSuggestions(raw) if err != nil { return SuggestionResult{}, err } out := SuggestionResult{ Suggestions: make([]Suggestion, 0, len(parsed)), ByFieldPath: map[string]Suggestion{}, } for _, item := range parsed { fieldPath := strings.TrimSpace(item.FieldPath) target, ok := allowed[fieldPath] if !ok { continue } value := strings.TrimSpace(item.Value) if value == "" { continue } suggestion := Suggestion{ FieldPath: fieldPath, Slot: firstNonEmpty(strings.TrimSpace(item.Slot), target.Slot), Value: value, Reason: firstNonEmpty(strings.TrimSpace(item.Reason), "provider suggestion"), Source: provider, } if _, exists := out.ByFieldPath[fieldPath]; exists { continue } out.Suggestions = append(out.Suggestions, suggestion) out.ByFieldPath[fieldPath] = suggestion } return out, nil } type providerSuggestion struct { FieldPath string `json:"fieldPath"` Slot string `json:"slot,omitempty"` Value string `json:"value"` Reason string `json:"reason,omitempty"` } func parseProviderSuggestions(raw string) ([]providerSuggestion, error) { content := strings.TrimSpace(raw) if content == "" { return nil, fmt.Errorf("empty provider response") } candidates := []string{content} if fence := extractFencedJSON(content); fence != "" { candidates = append([]string{fence}, candidates...) } if object := extractJSONObject(content); object != "" { candidates = append(candidates, object) } for _, candidate := range candidates { items, ok := parseSuggestionsCandidate(candidate) if ok { return items, nil } } return nil, fmt.Errorf("provider response is not valid suggestions json") } func parseSuggestionsCandidate(raw string) ([]providerSuggestion, bool) { var objectPayload struct { Suggestions []providerSuggestion `json:"suggestions"` } if err := json.Unmarshal([]byte(raw), &objectPayload); err == nil && len(objectPayload.Suggestions) > 0 { return objectPayload.Suggestions, true } var listPayload []providerSuggestion if err := json.Unmarshal([]byte(raw), &listPayload); err == nil && len(listPayload) > 0 { return listPayload, true } return nil, false } func extractFencedJSON(value string) string { const fence = "```" start := strings.Index(value, fence) for start >= 0 { rest := value[start+len(fence):] end := strings.Index(rest, fence) if end < 0 { return "" } block := strings.TrimSpace(rest[:end]) block = strings.TrimPrefix(block, "json") block = strings.TrimPrefix(block, "JSON") block = strings.TrimSpace(block) if strings.HasPrefix(block, "{") || strings.HasPrefix(block, "[") { return block } nextOffset := start + len(fence) + end + len(fence) nextStart := strings.Index(value[nextOffset:], fence) if nextStart < 0 { break } start = nextOffset + nextStart } return "" } func extractJSONObject(value string) string { start := strings.IndexAny(value, "{[") if start < 0 { return "" } end := strings.LastIndexAny(value, "}]") if end <= start { return "" } return strings.TrimSpace(value[start : end+1]) } func buildProviderPrompts(req SuggestionRequest, targets []SemanticSlotTarget) (string, string) { targetPayload := make([]map[string]string, 0, len(targets)) for _, target := range targets { targetPayload = append(targetPayload, map[string]string{ "fieldPath": strings.TrimSpace(target.FieldPath), "slot": strings.TrimSpace(target.Slot), }) } contextPayload := map[string]any{ "globalData": req.GlobalData, "draftContext": llmDraftContextMap(req.DraftContext), "masterPrompt": strings.TrimSpace(req.MasterPrompt), "promptBlocks": enabledPromptBlocks(req.PromptBlocks), "targets": targetPayload, } contextJSON, _ := json.MarshalIndent(contextPayload, "", " ") system := "You generate website text suggestions. Return JSON only. Format: {\"suggestions\":[{\"fieldPath\":\"...\",\"slot\":\"...\",\"value\":\"...\",\"reason\":\"...\"}]}. Use only provided field paths. Keep values concise and in input language." user := "Generate suggestions for each target field using the provided context. Do not include markdown.\n\n" + string(contextJSON) return system, user } func apiKeyForProvider(provider string, settings domain.AppSettings) string { switch provider { case domain.LLMProviderOpenAI: return strings.TrimSpace(settings.OpenAIAPIKeyEncrypted) case domain.LLMProviderAnthropic: return strings.TrimSpace(settings.AnthropicAPIKeyEncrypted) case domain.LLMProviderGoogle: return strings.TrimSpace(settings.GoogleAPIKeyEncrypted) case domain.LLMProviderXAI: return strings.TrimSpace(settings.XAIAPIKeyEncrypted) case domain.LLMProviderOllama: return strings.TrimSpace(settings.OllamaAPIKeyEncrypted) default: return "" } }