diff --git a/internal/app/app.go b/internal/app/app.go index 5534755..0d4e88e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -77,10 +77,13 @@ func New(cfg config.Config) (*App, error) { buildSvc := buildsvc.New(qc, templateStore, manifestStore, buildStore, mappingSvc, time.Duration(cfg.PollTimeoutSeconds)*time.Second) providerRuntime := llmruntime.NewFactory(45 * time.Second) suggestionGenerator := mapping.NewCompositeSuggestionGenerator( - mapping.NewProviderAwareSuggestionGenerator(settingsStore, providerRuntime), + mapping.NewGooglePlacesSuggestionGenerator(settingsStore), mapping.NewCompositeSuggestionGenerator( - mapping.NewQCLLMSuggestionGenerator(qc), - mapping.NewRuleBasedSuggestionGenerator(), + mapping.NewProviderAwareSuggestionGenerator(settingsStore, providerRuntime), + mapping.NewCompositeSuggestionGenerator( + mapping.NewQCLLMSuggestionGenerator(qc), + mapping.NewRuleBasedSuggestionGenerator(), + ), ), ) pollingSvc := polling.New(buildSvc, buildStore, time.Duration(cfg.PollIntervalSeconds)*time.Second, cfg.PollMaxConcurrent, logger) @@ -110,6 +113,7 @@ func New(cfg config.Config) (*App, error) { baseSettings.GoogleAPIKeyEncrypted = existing.GoogleAPIKeyEncrypted baseSettings.XAIAPIKeyEncrypted = existing.XAIAPIKeyEncrypted baseSettings.OllamaAPIKeyEncrypted = existing.OllamaAPIKeyEncrypted + baseSettings.GooglePlacesAPIKeyEncrypted = existing.GooglePlacesAPIKeyEncrypted baseSettings.MasterPrompt = existing.MasterPrompt baseSettings.PromptBlocks = existing.PromptBlocks } diff --git a/internal/domain/models.go b/internal/domain/models.go index ef78d53..ed0ebe4 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -162,6 +162,7 @@ type AppSettings struct { GoogleAPIKeyEncrypted string `json:"googleApiKeyEncrypted,omitempty"` XAIAPIKeyEncrypted string `json:"xaiApiKeyEncrypted,omitempty"` OllamaAPIKeyEncrypted string `json:"ollamaApiKeyEncrypted,omitempty"` + GooglePlacesAPIKeyEncrypted string `json:"googlePlacesApiKeyEncrypted,omitempty"` MasterPrompt string `json:"masterPrompt,omitempty"` PromptBlocks []PromptBlockConfig `json:"promptBlocks,omitempty"` } diff --git a/internal/httpserver/handlers/handlers.go b/internal/httpserver/handlers/handlers.go index 5574a74..71cab1b 100644 --- a/internal/httpserver/handlers/handlers.go +++ b/internal/httpserver/handlers/handlers.go @@ -212,6 +212,7 @@ func (a *API) IntakeDraft(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(req.BusinessType) != "" && strings.TrimSpace(getMapString(globalData, "businessType")) == "" { globalData["businessType"] = strings.TrimSpace(req.BusinessType) } + normalizeFlatAddress(globalData) styleProfile := domain.DraftStyleProfile{ LocaleStyle: strings.TrimSpace(req.LocaleStyle), @@ -396,3 +397,50 @@ func getMapString(values map[string]any, key string) string { raw, _ := values[key].(string) return raw } + +// normalizeFlatAddress folds flat addressXxx keys (as sent by Leadharvester) +// into the nested globalData["address"] map the rest of the codebase expects. +// Nested values win over flat ones if both are present. +func normalizeFlatAddress(globalData map[string]any) { + if globalData == nil { + return + } + mapping := map[string]string{ + "addressLine1": "line1", + "addressLine2": "line2", + "addressCity": "city", + "addressRegion": "region", + "addressZIP": "zip", + "addressCountry": "country", + } + address, _ := globalData["address"].(map[string]any) + if address == nil { + address = map[string]any{} + } + changed := false + for flat, nested := range mapping { + raw, ok := globalData[flat] + if !ok { + continue + } + delete(globalData, flat) + value, ok := raw.(string) + if !ok { + continue + } + value = strings.TrimSpace(value) + if value == "" { + continue + } + if existing, _ := address[nested].(string); strings.TrimSpace(existing) != "" { + continue + } + address[nested] = value + changed = true + } + if changed || len(address) > 0 { + if len(address) > 0 { + globalData["address"] = address + } + } +} diff --git a/internal/httpserver/handlers/ui.go b/internal/httpserver/handlers/ui.go index cfb364a..7e5fd6c 100644 --- a/internal/httpserver/handlers/ui.go +++ b/internal/httpserver/handlers/ui.go @@ -75,6 +75,7 @@ type settingsPageData struct { GoogleKeyConfigured bool XAIKeyConfigured bool OllamaKeyConfigured bool + GooglePlacesKeyConfigured bool MasterPrompt string PromptBlocks []domain.PromptBlockConfig } @@ -275,6 +276,7 @@ func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { GoogleKeyConfigured: strings.TrimSpace(settings.GoogleAPIKeyEncrypted) != "", XAIKeyConfigured: strings.TrimSpace(settings.XAIAPIKeyEncrypted) != "", OllamaKeyConfigured: strings.TrimSpace(settings.OllamaAPIKeyEncrypted) != "", + GooglePlacesKeyConfigured: strings.TrimSpace(settings.GooglePlacesAPIKeyEncrypted) != "", MasterPrompt: settings.MasterPrompt, PromptBlocks: settings.PromptBlocks, }) @@ -849,6 +851,9 @@ func applyLLMSettingsForm(settings domain.AppSettings, r *http.Request) (domain. if value := strings.TrimSpace(r.FormValue("llm_api_key_ollama")); value != "" { next.OllamaAPIKeyEncrypted = value } + if value := strings.TrimSpace(r.FormValue("google_places_api_key")); value != "" { + next.GooglePlacesAPIKeyEncrypted = value + } return next, nil } @@ -1999,6 +2004,7 @@ func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings { settings.GoogleAPIKeyEncrypted = strings.TrimSpace(stored.GoogleAPIKeyEncrypted) settings.XAIAPIKeyEncrypted = strings.TrimSpace(stored.XAIAPIKeyEncrypted) settings.OllamaAPIKeyEncrypted = strings.TrimSpace(stored.OllamaAPIKeyEncrypted) + settings.GooglePlacesAPIKeyEncrypted = strings.TrimSpace(stored.GooglePlacesAPIKeyEncrypted) settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt) settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks) return settings diff --git a/internal/mapping/google_places_suggestions.go b/internal/mapping/google_places_suggestions.go new file mode 100644 index 0000000..58d495d --- /dev/null +++ b/internal/mapping/google_places_suggestions.go @@ -0,0 +1,257 @@ +package mapping + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "qctextbuilder/internal/domain" +) + +type GooglePlacesSuggestionGenerator struct { + settings SettingsReader + httpClient *http.Client +} + +func NewGooglePlacesSuggestionGenerator(settings SettingsReader) *GooglePlacesSuggestionGenerator { + return &GooglePlacesSuggestionGenerator{ + settings: settings, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (g *GooglePlacesSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) { + if g == nil || g.settings == nil { + return SuggestionResult{}, fmt.Errorf("google places generator not configured") + } + appSettings, err := g.settings.GetSettings(ctx) + if err != nil || appSettings == nil { + return SuggestionResult{}, fmt.Errorf("settings not available") + } + apiKey := strings.TrimSpace(appSettings.GooglePlacesAPIKeyEncrypted) + if apiKey == "" { + return SuggestionResult{}, fmt.Errorf("google places api key not configured in settings") + } + + companyName := getMapString(req.GlobalData, "companyName") + city := "" + if addr, ok := req.GlobalData["address"].(map[string]any); ok { + city = getMapString(addr, "city") + } + if strings.TrimSpace(companyName) == "" { + return SuggestionResult{}, fmt.Errorf("company name not available for places search") + } + + testimonialTargets := collectTestimonialTargets(req.Fields) + if len(testimonialTargets) == 0 { + return SuggestionResult{Suggestions: []Suggestion{}, ByFieldPath: map[string]Suggestion{}}, nil + } + + query := strings.TrimSpace(companyName) + if city != "" { + query += " " + city + } + placeID, err := g.findPlaceID(ctx, query, apiKey) + if err != nil { + return SuggestionResult{}, fmt.Errorf("places search failed: %w", err) + } + if placeID == "" { + return SuggestionResult{}, fmt.Errorf("place not found for %q", query) + } + + reviews, err := g.fetchReviews(ctx, placeID, apiKey) + if err != nil { + return SuggestionResult{}, fmt.Errorf("reviews fetch failed: %w", err) + } + + goodReviews := filterPlacesReviews(reviews) + if len(goodReviews) == 0 { + return SuggestionResult{}, fmt.Errorf("no suitable reviews found for %q", query) + } + + out := SuggestionResult{ + Suggestions: []Suggestion{}, + ByFieldPath: map[string]Suggestion{}, + } + + byIndex := groupTestimonialTargetsByIndex(testimonialTargets) + indices := make([]int, 0, len(byIndex)) + for idx := range byIndex { + indices = append(indices, idx) + } + sort.Ints(indices) + + for i, slotIdx := range indices { + if i >= len(goodReviews) { + break + } + review := goodReviews[i] + for _, target := range byIndex[slotIdx] { + var value string + switch { + case strings.HasSuffix(target.Slot, ".name"): + value = strings.TrimSpace(review.AuthorName) + case strings.HasSuffix(target.Slot, ".description"): + value = shortenSentence(strings.TrimSpace(review.Text), 500) + default: + continue + } + if value == "" { + continue + } + s := Suggestion{ + FieldPath: target.FieldPath, + Slot: target.Slot, + Value: value, + Reason: fmt.Sprintf("google places review (rating %d/5)", review.Rating), + Source: "google_places", + } + out.Suggestions = append(out.Suggestions, s) + out.ByFieldPath[target.FieldPath] = s + } + } + + sort.SliceStable(out.Suggestions, func(i, j int) bool { + return out.Suggestions[i].FieldPath < out.Suggestions[j].FieldPath + }) + return out, nil +} + +type placesReview struct { + AuthorName string `json:"author_name"` + Text string `json:"text"` + Rating int `json:"rating"` +} + +func (g *GooglePlacesSuggestionGenerator) findPlaceID(ctx context.Context, query, apiKey string) (string, error) { + reqURL := "https://maps.googleapis.com/maps/api/place/textsearch/json?query=" + + url.QueryEscape(query) + "&key=" + apiKey + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return "", err + } + resp, err := g.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return "", fmt.Errorf("places textsearch http %d", resp.StatusCode) + } + + var result struct { + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + Results []struct { + PlaceID string `json:"place_id"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + switch result.Status { + case "OK": + // continue + case "ZERO_RESULTS": + return "", nil + default: + msg := strings.TrimSpace(result.ErrorMessage) + if msg == "" { + return "", fmt.Errorf("places textsearch status %s", result.Status) + } + return "", fmt.Errorf("places textsearch status %s: %s", result.Status, msg) + } + if len(result.Results) == 0 { + return "", nil + } + return result.Results[0].PlaceID, nil +} + +func (g *GooglePlacesSuggestionGenerator) fetchReviews(ctx context.Context, placeID, apiKey string) ([]placesReview, error) { + reqURL := "https://maps.googleapis.com/maps/api/place/details/json?place_id=" + + url.QueryEscape(placeID) + "&fields=reviews&reviews_sort=most_relevant&key=" + apiKey + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + resp, err := g.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("places details http %d", resp.StatusCode) + } + + var result struct { + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + Result struct { + Reviews []placesReview `json:"reviews"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + switch result.Status { + case "OK": + return result.Result.Reviews, nil + case "ZERO_RESULTS", "NOT_FOUND": + return nil, nil + default: + msg := strings.TrimSpace(result.ErrorMessage) + if msg == "" { + return nil, fmt.Errorf("places details status %s", result.Status) + } + return nil, fmt.Errorf("places details status %s: %s", result.Status, msg) + } +} + +func filterPlacesReviews(reviews []placesReview) []placesReview { + out := make([]placesReview, 0, len(reviews)) + for _, r := range reviews { + if r.Rating < 4 { + continue + } + if len([]rune(strings.TrimSpace(r.Text))) < 40 { + continue + } + out = append(out, r) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Rating != out[j].Rating { + return out[i].Rating > out[j].Rating + } + return len(out[i].Text) > len(out[j].Text) + }) + if len(out) > 5 { + out = out[:5] + } + return out +} + +func collectTestimonialTargets(fields []domain.TemplateField) []SemanticSlotTarget { + mappingResult := MapTemplateFieldsToSemanticSlots(fields) + out := make([]SemanticSlotTarget, 0) + for _, target := range mappingResult.Targets { + if strings.HasPrefix(target.Slot, "testimonial_items[") { + out = append(out, target) + } + } + return out +} + +func groupTestimonialTargetsByIndex(targets []SemanticSlotTarget) map[int][]SemanticSlotTarget { + out := map[int][]SemanticSlotTarget{} + for _, target := range targets { + idx := repeatedSlotIndex(target.Slot) + out[idx] = append(out[idx], target) + } + return out +} + diff --git a/internal/store/sqlite/migrations/008_add_google_places_api_key.sql b/internal/store/sqlite/migrations/008_add_google_places_api_key.sql new file mode 100644 index 0000000..a015fb7 --- /dev/null +++ b/internal/store/sqlite/migrations/008_add_google_places_api_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE app_settings +ADD COLUMN google_places_api_key_encrypted TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/sqlite/store.go b/internal/store/sqlite/store.go index 9f7b0e0..25c7f48 100644 --- a/internal/store/sqlite/store.go +++ b/internal/store/sqlite/store.go @@ -417,8 +417,9 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, llm_active_provider, llm_active_model, llm_base_url, llm_temperature, llm_max_tokens, openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted, + google_places_api_key_encrypted, master_prompt, prompt_blocks_json, updated_at - ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET qc_base_url = excluded.qc_base_url, qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted, @@ -435,6 +436,7 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) google_api_key_encrypted = excluded.google_api_key_encrypted, xai_api_key_encrypted = excluded.xai_api_key_encrypted, ollama_api_key_encrypted = excluded.ollama_api_key_encrypted, + google_places_api_key_encrypted = excluded.google_places_api_key_encrypted, master_prompt = excluded.master_prompt, prompt_blocks_json = excluded.prompt_blocks_json, updated_at = excluded.updated_at`, @@ -453,6 +455,7 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) strings.TrimSpace(settings.GoogleAPIKeyEncrypted), strings.TrimSpace(settings.XAIAPIKeyEncrypted), strings.TrimSpace(settings.OllamaAPIKeyEncrypted), + strings.TrimSpace(settings.GooglePlacesAPIKeyEncrypted), domain.NormalizeMasterPrompt(settings.MasterPrompt), promptBlocksRaw, time.Now().UTC().Format(time.RFC3339Nano), @@ -465,6 +468,7 @@ func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, llm_active_provider, llm_active_model, llm_base_url, llm_temperature, llm_max_tokens, openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted, + google_places_api_key_encrypted, master_prompt, prompt_blocks_json FROM app_settings WHERE id = 1`) @@ -486,6 +490,7 @@ func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { &settings.GoogleAPIKeyEncrypted, &settings.XAIAPIKeyEncrypted, &settings.OllamaAPIKeyEncrypted, + &settings.GooglePlacesAPIKeyEncrypted, &settings.MasterPrompt, &promptBlocksRaw, ); err != nil { @@ -508,6 +513,7 @@ func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { settings.GoogleAPIKeyEncrypted = strings.TrimSpace(settings.GoogleAPIKeyEncrypted) settings.XAIAPIKeyEncrypted = strings.TrimSpace(settings.XAIAPIKeyEncrypted) settings.OllamaAPIKeyEncrypted = strings.TrimSpace(settings.OllamaAPIKeyEncrypted) + settings.GooglePlacesAPIKeyEncrypted = strings.TrimSpace(settings.GooglePlacesAPIKeyEncrypted) settings.PromptBlocks = domain.NormalizePromptBlocks(settings.PromptBlocks) return &settings, nil } diff --git a/web/templates/settings.gohtml b/web/templates/settings.gohtml index 33c4bcb..97d0d52 100644 --- a/web/templates/settings.gohtml +++ b/web/templates/settings.gohtml @@ -81,6 +81,12 @@ +
+ +