Преглед изворни кода

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 месеци
родитељ
комит
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)
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
}


+ 1
- 0
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"`
}

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

+ 6
- 0
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


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


+ 6
- 0
web/templates/settings.gohtml Прегледај датотеку

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


Loading…
Откажи
Сачувај