- Google Places Suggestion Generator (Text Search + Place Details) zieht
Author/Text aus Top-Reviews und fuellt testimonial_items[N].name/.description.
API-Key kommt aus den Settings (neues Feld GooglePlacesAPIKeyEncrypted,
Migration 008, Settings-UI-Input).
- Generator wird in der Composite-Chain vor den LLM-Generatoren eingehaengt;
Fehler/kein Key fallen automatisch auf den LLM-Pfad zurueck.
- IntakeDraft normalisiert flache addressXxx-Felder (wie Leadharvester sie
liefert) in globalData.address.{line1,city,...}, damit Build-Form und
Places-Generator die Adresse korrekt sehen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
| @@ -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 | |||
| } | |||
| @@ -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"` | |||
| } | |||
| @@ -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 | |||
| } | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -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 | |||
| } | |||
| @@ -0,0 +1,2 @@ | |||
| ALTER TABLE app_settings | |||
| ADD COLUMN google_places_api_key_encrypted TEXT NOT NULL DEFAULT ''; | |||
| @@ -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 | |||
| } | |||
| @@ -81,6 +81,12 @@ | |||
| <input type="password" name="llm_api_key_ollama" placeholder="leer lassen = unveraendert"> | |||
| </label> | |||
| </div> | |||
| <div> | |||
| <label>Google Places API Key ({{if .GooglePlacesKeyConfigured}}configured{{else}}not configured{{end}}) | |||
| <input type="password" name="google_places_api_key" placeholder="leer lassen = unveraendert"> | |||
| <small>Wird fuer das Auto-Befuellen der Testimonials aus Google-Bewertungen verwendet.</small> | |||
| </label> | |||
| </div> | |||
| <button type="submit" formaction="/settings/llm/validate">Validate provider config</button> | |||
| <button type="submit">LLM-Settings speichern</button> | |||
| </form> | |||