No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.

258 líneas
7.0KB

  1. package mapping
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "net/url"
  8. "sort"
  9. "strings"
  10. "time"
  11. "qctextbuilder/internal/domain"
  12. )
  13. type GooglePlacesSuggestionGenerator struct {
  14. settings SettingsReader
  15. httpClient *http.Client
  16. }
  17. func NewGooglePlacesSuggestionGenerator(settings SettingsReader) *GooglePlacesSuggestionGenerator {
  18. return &GooglePlacesSuggestionGenerator{
  19. settings: settings,
  20. httpClient: &http.Client{Timeout: 10 * time.Second},
  21. }
  22. }
  23. func (g *GooglePlacesSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) {
  24. if g == nil || g.settings == nil {
  25. return SuggestionResult{}, fmt.Errorf("google places generator not configured")
  26. }
  27. appSettings, err := g.settings.GetSettings(ctx)
  28. if err != nil || appSettings == nil {
  29. return SuggestionResult{}, fmt.Errorf("settings not available")
  30. }
  31. apiKey := strings.TrimSpace(appSettings.GooglePlacesAPIKeyEncrypted)
  32. if apiKey == "" {
  33. return SuggestionResult{}, fmt.Errorf("google places api key not configured in settings")
  34. }
  35. companyName := getMapString(req.GlobalData, "companyName")
  36. city := ""
  37. if addr, ok := req.GlobalData["address"].(map[string]any); ok {
  38. city = getMapString(addr, "city")
  39. }
  40. if strings.TrimSpace(companyName) == "" {
  41. return SuggestionResult{}, fmt.Errorf("company name not available for places search")
  42. }
  43. testimonialTargets := collectTestimonialTargets(req.Fields)
  44. if len(testimonialTargets) == 0 {
  45. return SuggestionResult{Suggestions: []Suggestion{}, ByFieldPath: map[string]Suggestion{}}, nil
  46. }
  47. query := strings.TrimSpace(companyName)
  48. if city != "" {
  49. query += " " + city
  50. }
  51. placeID, err := g.findPlaceID(ctx, query, apiKey)
  52. if err != nil {
  53. return SuggestionResult{}, fmt.Errorf("places search failed: %w", err)
  54. }
  55. if placeID == "" {
  56. return SuggestionResult{}, fmt.Errorf("place not found for %q", query)
  57. }
  58. reviews, err := g.fetchReviews(ctx, placeID, apiKey)
  59. if err != nil {
  60. return SuggestionResult{}, fmt.Errorf("reviews fetch failed: %w", err)
  61. }
  62. goodReviews := filterPlacesReviews(reviews)
  63. if len(goodReviews) == 0 {
  64. return SuggestionResult{}, fmt.Errorf("no suitable reviews found for %q", query)
  65. }
  66. out := SuggestionResult{
  67. Suggestions: []Suggestion{},
  68. ByFieldPath: map[string]Suggestion{},
  69. }
  70. byIndex := groupTestimonialTargetsByIndex(testimonialTargets)
  71. indices := make([]int, 0, len(byIndex))
  72. for idx := range byIndex {
  73. indices = append(indices, idx)
  74. }
  75. sort.Ints(indices)
  76. for i, slotIdx := range indices {
  77. if i >= len(goodReviews) {
  78. break
  79. }
  80. review := goodReviews[i]
  81. for _, target := range byIndex[slotIdx] {
  82. var value string
  83. switch {
  84. case strings.HasSuffix(target.Slot, ".name"):
  85. value = strings.TrimSpace(review.AuthorName)
  86. case strings.HasSuffix(target.Slot, ".description"):
  87. value = shortenSentence(strings.TrimSpace(review.Text), 500)
  88. default:
  89. continue
  90. }
  91. if value == "" {
  92. continue
  93. }
  94. s := Suggestion{
  95. FieldPath: target.FieldPath,
  96. Slot: target.Slot,
  97. Value: value,
  98. Reason: fmt.Sprintf("google places review (rating %d/5)", review.Rating),
  99. Source: "google_places",
  100. }
  101. out.Suggestions = append(out.Suggestions, s)
  102. out.ByFieldPath[target.FieldPath] = s
  103. }
  104. }
  105. sort.SliceStable(out.Suggestions, func(i, j int) bool {
  106. return out.Suggestions[i].FieldPath < out.Suggestions[j].FieldPath
  107. })
  108. return out, nil
  109. }
  110. type placesReview struct {
  111. AuthorName string `json:"author_name"`
  112. Text string `json:"text"`
  113. Rating int `json:"rating"`
  114. }
  115. func (g *GooglePlacesSuggestionGenerator) findPlaceID(ctx context.Context, query, apiKey string) (string, error) {
  116. reqURL := "https://maps.googleapis.com/maps/api/place/textsearch/json?query=" +
  117. url.QueryEscape(query) + "&key=" + apiKey
  118. req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
  119. if err != nil {
  120. return "", err
  121. }
  122. resp, err := g.httpClient.Do(req)
  123. if err != nil {
  124. return "", err
  125. }
  126. defer resp.Body.Close()
  127. if resp.StatusCode/100 != 2 {
  128. return "", fmt.Errorf("places textsearch http %d", resp.StatusCode)
  129. }
  130. var result struct {
  131. Status string `json:"status"`
  132. ErrorMessage string `json:"error_message"`
  133. Results []struct {
  134. PlaceID string `json:"place_id"`
  135. } `json:"results"`
  136. }
  137. if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
  138. return "", err
  139. }
  140. switch result.Status {
  141. case "OK":
  142. // continue
  143. case "ZERO_RESULTS":
  144. return "", nil
  145. default:
  146. msg := strings.TrimSpace(result.ErrorMessage)
  147. if msg == "" {
  148. return "", fmt.Errorf("places textsearch status %s", result.Status)
  149. }
  150. return "", fmt.Errorf("places textsearch status %s: %s", result.Status, msg)
  151. }
  152. if len(result.Results) == 0 {
  153. return "", nil
  154. }
  155. return result.Results[0].PlaceID, nil
  156. }
  157. func (g *GooglePlacesSuggestionGenerator) fetchReviews(ctx context.Context, placeID, apiKey string) ([]placesReview, error) {
  158. reqURL := "https://maps.googleapis.com/maps/api/place/details/json?place_id=" +
  159. url.QueryEscape(placeID) + "&fields=reviews&reviews_sort=most_relevant&key=" + apiKey
  160. req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
  161. if err != nil {
  162. return nil, err
  163. }
  164. resp, err := g.httpClient.Do(req)
  165. if err != nil {
  166. return nil, err
  167. }
  168. defer resp.Body.Close()
  169. if resp.StatusCode/100 != 2 {
  170. return nil, fmt.Errorf("places details http %d", resp.StatusCode)
  171. }
  172. var result struct {
  173. Status string `json:"status"`
  174. ErrorMessage string `json:"error_message"`
  175. Result struct {
  176. Reviews []placesReview `json:"reviews"`
  177. } `json:"result"`
  178. }
  179. if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
  180. return nil, err
  181. }
  182. switch result.Status {
  183. case "OK":
  184. return result.Result.Reviews, nil
  185. case "ZERO_RESULTS", "NOT_FOUND":
  186. return nil, nil
  187. default:
  188. msg := strings.TrimSpace(result.ErrorMessage)
  189. if msg == "" {
  190. return nil, fmt.Errorf("places details status %s", result.Status)
  191. }
  192. return nil, fmt.Errorf("places details status %s: %s", result.Status, msg)
  193. }
  194. }
  195. func filterPlacesReviews(reviews []placesReview) []placesReview {
  196. out := make([]placesReview, 0, len(reviews))
  197. for _, r := range reviews {
  198. if r.Rating < 4 {
  199. continue
  200. }
  201. if len([]rune(strings.TrimSpace(r.Text))) < 40 {
  202. continue
  203. }
  204. out = append(out, r)
  205. }
  206. sort.SliceStable(out, func(i, j int) bool {
  207. if out[i].Rating != out[j].Rating {
  208. return out[i].Rating > out[j].Rating
  209. }
  210. return len(out[i].Text) > len(out[j].Text)
  211. })
  212. if len(out) > 5 {
  213. out = out[:5]
  214. }
  215. return out
  216. }
  217. func collectTestimonialTargets(fields []domain.TemplateField) []SemanticSlotTarget {
  218. mappingResult := MapTemplateFieldsToSemanticSlots(fields)
  219. out := make([]SemanticSlotTarget, 0)
  220. for _, target := range mappingResult.Targets {
  221. if strings.HasPrefix(target.Slot, "testimonial_items[") {
  222. out = append(out, target)
  223. }
  224. }
  225. return out
  226. }
  227. func groupTestimonialTargetsByIndex(targets []SemanticSlotTarget) map[int][]SemanticSlotTarget {
  228. out := map[int][]SemanticSlotTarget{}
  229. for _, target := range targets {
  230. idx := repeatedSlotIndex(target.Slot)
  231. out[idx] = append(out[idx], target)
  232. }
  233. return out
  234. }