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 }