- 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) | buildSvc := buildsvc.New(qc, templateStore, manifestStore, buildStore, mappingSvc, time.Duration(cfg.PollTimeoutSeconds)*time.Second) | ||||
| providerRuntime := llmruntime.NewFactory(45 * time.Second) | providerRuntime := llmruntime.NewFactory(45 * time.Second) | ||||
| suggestionGenerator := mapping.NewCompositeSuggestionGenerator( | suggestionGenerator := mapping.NewCompositeSuggestionGenerator( | ||||
| mapping.NewProviderAwareSuggestionGenerator(settingsStore, providerRuntime), | |||||
| mapping.NewGooglePlacesSuggestionGenerator(settingsStore), | |||||
| mapping.NewCompositeSuggestionGenerator( | 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) | 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.GoogleAPIKeyEncrypted = existing.GoogleAPIKeyEncrypted | ||||
| baseSettings.XAIAPIKeyEncrypted = existing.XAIAPIKeyEncrypted | baseSettings.XAIAPIKeyEncrypted = existing.XAIAPIKeyEncrypted | ||||
| baseSettings.OllamaAPIKeyEncrypted = existing.OllamaAPIKeyEncrypted | baseSettings.OllamaAPIKeyEncrypted = existing.OllamaAPIKeyEncrypted | ||||
| baseSettings.GooglePlacesAPIKeyEncrypted = existing.GooglePlacesAPIKeyEncrypted | |||||
| baseSettings.MasterPrompt = existing.MasterPrompt | baseSettings.MasterPrompt = existing.MasterPrompt | ||||
| baseSettings.PromptBlocks = existing.PromptBlocks | baseSettings.PromptBlocks = existing.PromptBlocks | ||||
| } | } | ||||
| @@ -162,6 +162,7 @@ type AppSettings struct { | |||||
| GoogleAPIKeyEncrypted string `json:"googleApiKeyEncrypted,omitempty"` | GoogleAPIKeyEncrypted string `json:"googleApiKeyEncrypted,omitempty"` | ||||
| XAIAPIKeyEncrypted string `json:"xaiApiKeyEncrypted,omitempty"` | XAIAPIKeyEncrypted string `json:"xaiApiKeyEncrypted,omitempty"` | ||||
| OllamaAPIKeyEncrypted string `json:"ollamaApiKeyEncrypted,omitempty"` | OllamaAPIKeyEncrypted string `json:"ollamaApiKeyEncrypted,omitempty"` | ||||
| GooglePlacesAPIKeyEncrypted string `json:"googlePlacesApiKeyEncrypted,omitempty"` | |||||
| MasterPrompt string `json:"masterPrompt,omitempty"` | MasterPrompt string `json:"masterPrompt,omitempty"` | ||||
| PromptBlocks []PromptBlockConfig `json:"promptBlocks,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")) == "" { | if strings.TrimSpace(req.BusinessType) != "" && strings.TrimSpace(getMapString(globalData, "businessType")) == "" { | ||||
| globalData["businessType"] = strings.TrimSpace(req.BusinessType) | globalData["businessType"] = strings.TrimSpace(req.BusinessType) | ||||
| } | } | ||||
| normalizeFlatAddress(globalData) | |||||
| styleProfile := domain.DraftStyleProfile{ | styleProfile := domain.DraftStyleProfile{ | ||||
| LocaleStyle: strings.TrimSpace(req.LocaleStyle), | LocaleStyle: strings.TrimSpace(req.LocaleStyle), | ||||
| @@ -396,3 +397,50 @@ func getMapString(values map[string]any, key string) string { | |||||
| raw, _ := values[key].(string) | raw, _ := values[key].(string) | ||||
| return raw | 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 | GoogleKeyConfigured bool | ||||
| XAIKeyConfigured bool | XAIKeyConfigured bool | ||||
| OllamaKeyConfigured bool | OllamaKeyConfigured bool | ||||
| GooglePlacesKeyConfigured bool | |||||
| MasterPrompt string | MasterPrompt string | ||||
| PromptBlocks []domain.PromptBlockConfig | PromptBlocks []domain.PromptBlockConfig | ||||
| } | } | ||||
| @@ -275,6 +276,7 @@ func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { | |||||
| GoogleKeyConfigured: strings.TrimSpace(settings.GoogleAPIKeyEncrypted) != "", | GoogleKeyConfigured: strings.TrimSpace(settings.GoogleAPIKeyEncrypted) != "", | ||||
| XAIKeyConfigured: strings.TrimSpace(settings.XAIAPIKeyEncrypted) != "", | XAIKeyConfigured: strings.TrimSpace(settings.XAIAPIKeyEncrypted) != "", | ||||
| OllamaKeyConfigured: strings.TrimSpace(settings.OllamaAPIKeyEncrypted) != "", | OllamaKeyConfigured: strings.TrimSpace(settings.OllamaAPIKeyEncrypted) != "", | ||||
| GooglePlacesKeyConfigured: strings.TrimSpace(settings.GooglePlacesAPIKeyEncrypted) != "", | |||||
| MasterPrompt: settings.MasterPrompt, | MasterPrompt: settings.MasterPrompt, | ||||
| PromptBlocks: settings.PromptBlocks, | 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 != "" { | if value := strings.TrimSpace(r.FormValue("llm_api_key_ollama")); value != "" { | ||||
| next.OllamaAPIKeyEncrypted = value | next.OllamaAPIKeyEncrypted = value | ||||
| } | } | ||||
| if value := strings.TrimSpace(r.FormValue("google_places_api_key")); value != "" { | |||||
| next.GooglePlacesAPIKeyEncrypted = value | |||||
| } | |||||
| return next, nil | return next, nil | ||||
| } | } | ||||
| @@ -1999,6 +2004,7 @@ func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings { | |||||
| settings.GoogleAPIKeyEncrypted = strings.TrimSpace(stored.GoogleAPIKeyEncrypted) | settings.GoogleAPIKeyEncrypted = strings.TrimSpace(stored.GoogleAPIKeyEncrypted) | ||||
| settings.XAIAPIKeyEncrypted = strings.TrimSpace(stored.XAIAPIKeyEncrypted) | settings.XAIAPIKeyEncrypted = strings.TrimSpace(stored.XAIAPIKeyEncrypted) | ||||
| settings.OllamaAPIKeyEncrypted = strings.TrimSpace(stored.OllamaAPIKeyEncrypted) | settings.OllamaAPIKeyEncrypted = strings.TrimSpace(stored.OllamaAPIKeyEncrypted) | ||||
| settings.GooglePlacesAPIKeyEncrypted = strings.TrimSpace(stored.GooglePlacesAPIKeyEncrypted) | |||||
| settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt) | settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt) | ||||
| settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks) | settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks) | ||||
| return settings | 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, | 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, | 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, | 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 | master_prompt, prompt_blocks_json, updated_at | ||||
| ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |||||
| ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |||||
| ON CONFLICT(id) DO UPDATE SET | ON CONFLICT(id) DO UPDATE SET | ||||
| qc_base_url = excluded.qc_base_url, | qc_base_url = excluded.qc_base_url, | ||||
| qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted, | 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, | google_api_key_encrypted = excluded.google_api_key_encrypted, | ||||
| xai_api_key_encrypted = excluded.xai_api_key_encrypted, | xai_api_key_encrypted = excluded.xai_api_key_encrypted, | ||||
| ollama_api_key_encrypted = excluded.ollama_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, | master_prompt = excluded.master_prompt, | ||||
| prompt_blocks_json = excluded.prompt_blocks_json, | prompt_blocks_json = excluded.prompt_blocks_json, | ||||
| updated_at = excluded.updated_at`, | 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.GoogleAPIKeyEncrypted), | ||||
| strings.TrimSpace(settings.XAIAPIKeyEncrypted), | strings.TrimSpace(settings.XAIAPIKeyEncrypted), | ||||
| strings.TrimSpace(settings.OllamaAPIKeyEncrypted), | strings.TrimSpace(settings.OllamaAPIKeyEncrypted), | ||||
| strings.TrimSpace(settings.GooglePlacesAPIKeyEncrypted), | |||||
| domain.NormalizeMasterPrompt(settings.MasterPrompt), | domain.NormalizeMasterPrompt(settings.MasterPrompt), | ||||
| promptBlocksRaw, | promptBlocksRaw, | ||||
| time.Now().UTC().Format(time.RFC3339Nano), | 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, | 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, | 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, | 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 | master_prompt, prompt_blocks_json | ||||
| FROM app_settings | FROM app_settings | ||||
| WHERE id = 1`) | WHERE id = 1`) | ||||
| @@ -486,6 +490,7 @@ func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { | |||||
| &settings.GoogleAPIKeyEncrypted, | &settings.GoogleAPIKeyEncrypted, | ||||
| &settings.XAIAPIKeyEncrypted, | &settings.XAIAPIKeyEncrypted, | ||||
| &settings.OllamaAPIKeyEncrypted, | &settings.OllamaAPIKeyEncrypted, | ||||
| &settings.GooglePlacesAPIKeyEncrypted, | |||||
| &settings.MasterPrompt, | &settings.MasterPrompt, | ||||
| &promptBlocksRaw, | &promptBlocksRaw, | ||||
| ); err != nil { | ); err != nil { | ||||
| @@ -508,6 +513,7 @@ func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { | |||||
| settings.GoogleAPIKeyEncrypted = strings.TrimSpace(settings.GoogleAPIKeyEncrypted) | settings.GoogleAPIKeyEncrypted = strings.TrimSpace(settings.GoogleAPIKeyEncrypted) | ||||
| settings.XAIAPIKeyEncrypted = strings.TrimSpace(settings.XAIAPIKeyEncrypted) | settings.XAIAPIKeyEncrypted = strings.TrimSpace(settings.XAIAPIKeyEncrypted) | ||||
| settings.OllamaAPIKeyEncrypted = strings.TrimSpace(settings.OllamaAPIKeyEncrypted) | settings.OllamaAPIKeyEncrypted = strings.TrimSpace(settings.OllamaAPIKeyEncrypted) | ||||
| settings.GooglePlacesAPIKeyEncrypted = strings.TrimSpace(settings.GooglePlacesAPIKeyEncrypted) | |||||
| settings.PromptBlocks = domain.NormalizePromptBlocks(settings.PromptBlocks) | settings.PromptBlocks = domain.NormalizePromptBlocks(settings.PromptBlocks) | ||||
| return &settings, nil | return &settings, nil | ||||
| } | } | ||||
| @@ -81,6 +81,12 @@ | |||||
| <input type="password" name="llm_api_key_ollama" placeholder="leer lassen = unveraendert"> | <input type="password" name="llm_api_key_ollama" placeholder="leer lassen = unveraendert"> | ||||
| </label> | </label> | ||||
| </div> | </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" formaction="/settings/llm/validate">Validate provider config</button> | ||||
| <button type="submit">LLM-Settings speichern</button> | <button type="submit">LLM-Settings speichern</button> | ||||
| </form> | </form> | ||||