|
- 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().InfoContext(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)),
- "response_snippet", providerLogSnippet(raw, 4000),
- )
- mappingLogger().InfoContext(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),
- "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),
- "suggestion_sample_sources", sampleResultSources(out, 5),
- "suggestion_sample", suggestionLogSample(out, 5),
- "duration_ms", time.Since(started).Milliseconds(),
- )
- 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(), "/")
- }
|