Просмотр исходного кода

feat: Google Places Testimonials Autofill + Adress-Normalisierung

- 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
Jan Svabenik 3 месяцев назад
Родитель
Сommit
5c888414a1
8 измененных файлов: 334 добавлений и 4 удалений
  1. +7
    -3
      internal/app/app.go
  2. +1
    -0
      internal/domain/models.go
  3. +48
    -0
      internal/httpserver/handlers/handlers.go
  4. +6
    -0
      internal/httpserver/handlers/ui.go
  5. +257
    -0
      internal/mapping/google_places_suggestions.go
  6. +2
    -0
      internal/store/sqlite/migrations/008_add_google_places_api_key.sql
  7. +7
    -1
      internal/store/sqlite/store.go
  8. +6
    -0
      web/templates/settings.gohtml

+ 7
- 3
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) 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
} }


+ 1
- 0
internal/domain/models.go Просмотреть файл

@@ -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"`
} }

+ 48
- 0
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")) == "" { 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
}
}
}

+ 6
- 0
internal/httpserver/handlers/ui.go Просмотреть файл

@@ -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


+ 257
- 0
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
}


+ 2
- 0
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 '';

+ 7
- 1
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, 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
} }


+ 6
- 0
web/templates/settings.gohtml Просмотреть файл

@@ -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>


Загрузка…
Отмена
Сохранить