package mapping import ( "context" "encoding/json" "fmt" "net/url" "sort" "strings" "time" "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) { started := time.Now() if g == nil || g.settings == nil || g.runtimeFactory == nil { mappingLogger().WarnContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_config", "status", "failed", "template_id", req.TemplateID, "error", "provider-aware generator is not configured", ) return SuggestionResult{}, fmt.Errorf("provider-aware generator is not configured") } settings, err := g.settings.GetSettings(ctx) if err != nil || settings == nil { mappingLogger().WarnContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_settings", "status", "failed", "template_id", req.TemplateID, "error", "llm settings are not available", "duration_ms", time.Since(started).Milliseconds(), ) return SuggestionResult{}, fmt.Errorf("llm settings are not available") } provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider) model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel) baseURL := strings.TrimSpace(settings.LLMBaseURL) mappingLogger().InfoContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_request", "status", "start", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), ) if strings.TrimSpace(model) == "" { mappingLogger().WarnContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_request", "status", "failed", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "error", "no active model configured", "duration_ms", time.Since(started).Milliseconds(), ) return SuggestionResult{}, fmt.Errorf("no active model configured") } apiKey := domain.LLMAPIKeyForProvider(provider, *settings) if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" { mappingLogger().WarnContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_request", "status", "failed", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "error", "missing api key", "duration_ms", time.Since(started).Milliseconds(), ) 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 { mappingLogger().WarnContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_request", "status", "failed", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "error", shortErr(err), "duration_ms", time.Since(started).Milliseconds(), ) return SuggestionResult{}, err } systemPrompt, userPrompt := buildProviderPrompts(req, targets) temperature := domain.NormalizeLLMTemperature(settings.LLMTemperature) maxTokens := domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens) mappingLogger().DebugContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_request_payload", "status", "start", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "base_url", llmruntimeSafeBaseURL(baseURL), "system_prompt_chars", len(systemPrompt), "system_prompt_snippet", providerLogSnippet(systemPrompt, 1500), "user_prompt_chars", len(userPrompt), "user_prompt_snippet", providerLogSnippet(userPrompt, 2000), ) raw, err := providerClient.Generate(ctx, llmruntime.Request{ Provider: provider, Model: model, BaseURL: baseURL, APIKey: strings.TrimSpace(apiKey), Temperature: &temperature, MaxTokens: &maxTokens, SystemPrompt: systemPrompt, UserPrompt: userPrompt, }) if err != nil { mappingLogger().WarnContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_request", "status", "failed", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "error", shortErr(err), "duration_ms", time.Since(started).Milliseconds(), ) return SuggestionResult{}, fmt.Errorf("provider request failed (provider=%s model=%s): %w", provider, model, err) } mappingLogger().InfoContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_request", "status", "success", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "response_chars", len(strings.TrimSpace(raw)), ) mappingLogger().DebugContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_request", "status", "success", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "response_snippet", providerLogSnippet(raw, 4000), ) mappingLogger().DebugContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_parse_input", "status", "start", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "extracted_content_chars", len(strings.TrimSpace(raw)), "extracted_content_snippet", providerLogSnippet(raw, 4000), ) parsed, err := parseProviderSuggestions(raw) if err != nil { mappingLogger().WarnContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_parse", "status", "failed", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "error", shortErr(err), "duration_ms", time.Since(started).Milliseconds(), ) return SuggestionResult{}, fmt.Errorf("provider returned invalid suggestions json (provider=%s model=%s): %w", provider, model, err) } mappingLogger().InfoContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_parse", "status", "success", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "parsed_count", len(parsed), ) mappingLogger().DebugContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_parse", "status", "success", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "parsed_sample", providerSuggestionSample(parsed, 5), ) 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 } mappingLogger().InfoContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_result", "status", "success", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "suggestion_count", len(out.Suggestions), "sources", summarizeResultSources(out), "duration_ms", time.Since(started).Milliseconds(), ) mappingLogger().DebugContext(ctx, "provider-aware suggestion", "component", "autofill", "step", "provider_aware_result", "status", "success", "provider", provider, "model", model, "template_id", req.TemplateID, "draft_id", strings.TrimSpace(req.DraftID), "suggestion_sample_sources", sampleResultSources(out, 5), "suggestion_sample", suggestionLogSample(out, 5), ) 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) } } func providerLogSnippet(value string, limit int) string { trimmed := strings.TrimSpace(value) if trimmed == "" || limit <= 0 { return "" } runes := []rune(trimmed) if len(runes) <= limit { return trimmed } return strings.TrimSpace(string(runes[:limit])) + "...(truncated)" } func providerSuggestionSample(items []providerSuggestion, limit int) []map[string]string { if len(items) == 0 || limit <= 0 { return []map[string]string{} } if len(items) > limit { items = items[:limit] } out := make([]map[string]string, 0, len(items)) for _, item := range items { out = append(out, map[string]string{ "fieldPath": strings.TrimSpace(item.FieldPath), "slot": strings.TrimSpace(item.Slot), "value": providerLogSnippet(item.Value, 200), "reason": providerLogSnippet(item.Reason, 120), }) } return out } func suggestionLogSample(result SuggestionResult, limit int) []map[string]string { if limit <= 0 || len(result.ByFieldPath) == 0 { return []map[string]string{} } paths := make([]string, 0, len(result.ByFieldPath)) for path := range result.ByFieldPath { paths = append(paths, path) } sort.Strings(paths) if len(paths) > limit { paths = paths[:limit] } out := make([]map[string]string, 0, len(paths)) for _, path := range paths { item := result.ByFieldPath[path] out = append(out, map[string]string{ "fieldPath": strings.TrimSpace(item.FieldPath), "source": strings.TrimSpace(item.Source), "slot": strings.TrimSpace(item.Slot), "value": providerLogSnippet(item.Value, 200), }) } return out } func llmruntimeSafeBaseURL(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { return "" } parsed, err := url.Parse(trimmed) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return trimmed } parsed.User = nil parsed.RawQuery = "" parsed.Fragment = "" return strings.TrimRight(parsed.String(), "/") }