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