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 := domain.LLMAPIKeyForProvider(provider, *settings) if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" { return SuggestionResult{}, fmt.Errorf("api key for provider %s is not configured in settings", 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) temperature := domain.NormalizeLLMTemperature(settings.LLMTemperature) maxTokens := domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens) raw, err := providerClient.Generate(ctx, llmruntime.Request{ Provider: provider, Model: model, BaseURL: strings.TrimSpace(settings.LLMBaseURL), APIKey: strings.TrimSpace(apiKey), Temperature: &temperature, MaxTokens: &maxTokens, SystemPrompt: systemPrompt, UserPrompt: userPrompt, }) if err != nil { return SuggestionResult{}, fmt.Errorf("provider request failed (provider=%s model=%s): %w", provider, model, err) } parsed, err := parseProviderSuggestions(raw) if err != nil { return SuggestionResult{}, fmt.Errorf("provider returned invalid suggestions json (provider=%s model=%s): %w", provider, model, 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) } var firstErr error for _, candidate := range candidates { items, err := parseSuggestionsCandidate(candidate) if err == nil { return items, nil } if firstErr == nil { firstErr = err } } if firstErr != nil { return nil, firstErr } return nil, fmt.Errorf("provider response is not valid suggestions json") } func parseSuggestionsCandidate(raw string) ([]providerSuggestion, error) { var root any if err := json.Unmarshal([]byte(raw), &root); err != nil { return nil, fmt.Errorf("provider response is not valid json: %w", err) } var itemsRaw []any switch value := root.(type) { case map[string]any: suggestions, ok := value["suggestions"] if !ok { return nil, fmt.Errorf("provider json object must contain \"suggestions\" array") } list, ok := suggestions.([]any) if !ok { return nil, fmt.Errorf("provider \"suggestions\" must be an array") } itemsRaw = list case []any: itemsRaw = value default: return nil, fmt.Errorf("provider json payload must be an object or array") } if len(itemsRaw) == 0 { return nil, fmt.Errorf("provider returned an empty suggestions array") } out := make([]providerSuggestion, 0, len(itemsRaw)) for idx, rawItem := range itemsRaw { itemMap, ok := rawItem.(map[string]any) if !ok { return nil, fmt.Errorf("suggestion #%d is not an object", idx+1) } fieldPath := strings.TrimSpace(anyToString(itemMap["fieldPath"])) if fieldPath == "" { return nil, fmt.Errorf("suggestion #%d has empty fieldPath", idx+1) } value := strings.TrimSpace(anyToString(itemMap["value"])) if value == "" { return nil, fmt.Errorf("suggestion #%d for fieldPath %q has empty value", idx+1, fieldPath) } out = append(out, providerSuggestion{ FieldPath: fieldPath, Slot: strings.TrimSpace(anyToString(itemMap["slot"])), Value: value, Reason: strings.TrimSpace(anyToString(itemMap["reason"])), }) } return out, nil } 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 anyToString(raw any) string { switch value := raw.(type) { case string: return value case float64: return fmt.Sprintf("%.0f", value) case bool: if value { return "true" } return "false" case nil: return "" default: return fmt.Sprintf("%v", value) } }