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.

274 líneas
7.4KB

  1. package onboarding
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "regexp"
  7. "sort"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "qctextbuilder/internal/domain"
  12. "qctextbuilder/internal/qcclient"
  13. "qctextbuilder/internal/store"
  14. )
  15. var imagePlaceholderPattern = regexp.MustCompile(`^\[\s*(image|img|photo|picture)(\s+(image|img|photo|picture))*\s*\]$`)
  16. var imageLikePathHints = []string{
  17. "image",
  18. "img",
  19. "photo",
  20. "picture",
  21. "thumbnail",
  22. "gallery",
  23. "logo",
  24. "icon",
  25. "avatar",
  26. "background",
  27. "banner",
  28. }
  29. type Service struct {
  30. qc qcclient.Client
  31. templateStore store.TemplateStore
  32. manifestStore store.ManifestStore
  33. }
  34. type FieldPatch struct {
  35. Path string
  36. IsEnabled *bool
  37. IsRequiredByUs *bool
  38. DisplayLabel *string
  39. DisplayOrder *int
  40. WebsiteSection *string
  41. Notes *string
  42. }
  43. func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore) *Service {
  44. return &Service{
  45. qc: qc,
  46. templateStore: templateStore,
  47. manifestStore: manifestStore,
  48. }
  49. }
  50. func (s *Service) OnboardTemplate(ctx context.Context, templateID int64) (*domain.TemplateManifest, []domain.TemplateField, error) {
  51. template, err := s.templateStore.GetTemplateByID(ctx, templateID)
  52. if err != nil {
  53. return nil, nil, fmt.Errorf("get template: %w", err)
  54. }
  55. if !template.IsAITemplate {
  56. return nil, nil, fmt.Errorf("invalid template type: only ai template is allowed")
  57. }
  58. req := defaultDiscoveryRequest(templateID)
  59. data, raw, err := s.qc.GenerateContent(ctx, req)
  60. if err != nil {
  61. return nil, nil, fmt.Errorf("generate discovery content: %w", err)
  62. }
  63. manifestID := strconv.FormatInt(time.Now().UnixNano(), 10)
  64. now := time.Now().UTC()
  65. fields := flattenDiscovery(templateID, manifestID, data)
  66. flattened, _ := json.Marshal(fields)
  67. reqRaw, _ := json.Marshal(req)
  68. manifest := domain.TemplateManifest{
  69. ID: manifestID,
  70. TemplateID: templateID,
  71. Version: 1,
  72. Source: "generate-content",
  73. LanguageUsedDiscovery: "EN",
  74. DiscoveryPayloadJSON: reqRaw,
  75. DiscoveryResponseJSON: raw,
  76. FlattenedManifestJSON: flattened,
  77. IsActive: true,
  78. CreatedAt: now,
  79. UpdatedAt: now,
  80. }
  81. if err := s.manifestStore.CreateManifest(ctx, manifest, fields); err != nil {
  82. return nil, nil, fmt.Errorf("save manifest: %w", err)
  83. }
  84. if err := s.templateStore.SetTemplateManifestStatus(ctx, templateID, "reviewed", true); err != nil {
  85. return nil, nil, fmt.Errorf("update template status: %w", err)
  86. }
  87. return &manifest, fields, nil
  88. }
  89. func (s *Service) UpdateTemplateFields(ctx context.Context, templateID int64, manifestID string, patches []FieldPatch) (*domain.TemplateManifest, []domain.TemplateField, error) {
  90. template, err := s.templateStore.GetTemplateByID(ctx, templateID)
  91. if err != nil {
  92. return nil, nil, fmt.Errorf("get template: %w", err)
  93. }
  94. if !template.IsAITemplate {
  95. return nil, nil, fmt.Errorf("invalid template type: only ai template is allowed")
  96. }
  97. manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, templateID)
  98. if err != nil {
  99. return nil, nil, fmt.Errorf("get active manifest: %w", err)
  100. }
  101. if strings.TrimSpace(manifestID) != "" && manifest.ID != manifestID {
  102. return nil, nil, fmt.Errorf("manifest mismatch: active=%s requested=%s", manifest.ID, manifestID)
  103. }
  104. fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID)
  105. if err != nil {
  106. return nil, nil, fmt.Errorf("list fields: %w", err)
  107. }
  108. byPath := make(map[string]int, len(fields))
  109. for i := range fields {
  110. byPath[fields[i].Path] = i
  111. }
  112. for _, patch := range patches {
  113. path := strings.TrimSpace(patch.Path)
  114. if path == "" {
  115. return nil, nil, fmt.Errorf("field patch path is required")
  116. }
  117. idx, ok := byPath[path]
  118. if !ok {
  119. return nil, nil, fmt.Errorf("unknown field path: %s", path)
  120. }
  121. if patch.IsEnabled != nil {
  122. if *patch.IsEnabled && fields[idx].FieldKind != "text" {
  123. return nil, nil, fmt.Errorf("field %s cannot be enabled for kind %s", path, fields[idx].FieldKind)
  124. }
  125. fields[idx].IsEnabled = *patch.IsEnabled
  126. }
  127. if patch.IsRequiredByUs != nil {
  128. fields[idx].IsRequiredByUs = *patch.IsRequiredByUs
  129. }
  130. if patch.DisplayLabel != nil {
  131. fields[idx].DisplayLabel = strings.TrimSpace(*patch.DisplayLabel)
  132. }
  133. if patch.DisplayOrder != nil {
  134. fields[idx].DisplayOrder = *patch.DisplayOrder
  135. }
  136. if patch.WebsiteSection != nil {
  137. fields[idx].WebsiteSection = domain.NormalizeWebsiteSection(*patch.WebsiteSection)
  138. }
  139. if patch.Notes != nil {
  140. fields[idx].Notes = strings.TrimSpace(*patch.Notes)
  141. }
  142. }
  143. if err := s.manifestStore.UpdateFields(ctx, manifest.ID, fields); err != nil {
  144. return nil, nil, fmt.Errorf("update fields: %w", err)
  145. }
  146. sort.Slice(fields, func(i, j int) bool {
  147. if fields[i].DisplayOrder == fields[j].DisplayOrder {
  148. return fields[i].Path < fields[j].Path
  149. }
  150. return fields[i].DisplayOrder < fields[j].DisplayOrder
  151. })
  152. return manifest, fields, nil
  153. }
  154. func defaultDiscoveryRequest(templateID int64) qcclient.GenerateContentRequest {
  155. return qcclient.GenerateContentRequest{
  156. TemplateID: templateID,
  157. GlobalData: map[string]any{
  158. "companyName": "Discovery Company",
  159. "businessType": "dentist",
  160. "siteLanguage": "EN",
  161. "email": "discovery@example.com",
  162. "phone": "+41 44 000 00 00",
  163. "address": map[string]any{
  164. "line1": "Discovery Street 1",
  165. "line2": "",
  166. "city": "Zurich",
  167. "region": "ZH",
  168. "postalCode": "8000",
  169. "country": "CH",
  170. },
  171. },
  172. Empty: false,
  173. ToneOfVoice: "Professional",
  174. TargetAudience: "B2B",
  175. }
  176. }
  177. func flattenDiscovery(templateID int64, manifestID string, data qcclient.GenerateContentData) []domain.TemplateField {
  178. fields := make([]domain.TemplateField, 0)
  179. order := 0
  180. for section, kv := range data {
  181. for key, value := range kv {
  182. sample := fmt.Sprint(value)
  183. path := section + "." + key
  184. kind := detectFieldKind(path, sample)
  185. enabled := kind == "text"
  186. fields = append(fields, domain.TemplateField{
  187. ID: fmt.Sprintf("%s-%d", manifestID, order+1),
  188. TemplateID: templateID,
  189. ManifestID: manifestID,
  190. Section: section,
  191. WebsiteSection: domain.SuggestWebsiteSection(domain.TemplateField{
  192. Section: section,
  193. KeyName: key,
  194. Path: path,
  195. FieldKind: kind,
  196. SampleValue: sample,
  197. }),
  198. KeyName: key,
  199. Path: path,
  200. FieldKind: kind,
  201. SampleValue: sample,
  202. IsEnabled: enabled,
  203. DisplayLabel: path,
  204. DisplayOrder: order,
  205. Notes: "",
  206. })
  207. order++
  208. }
  209. }
  210. return fields
  211. }
  212. func detectFieldKind(path string, sample string) string {
  213. sampleTrim := strings.TrimSpace(sample)
  214. if sampleTrim == "" {
  215. return "unknown"
  216. }
  217. if strings.EqualFold(sampleTrim, "#IMAGE#") {
  218. return "image"
  219. }
  220. if isLikelyImagePlaceholder(sampleTrim) {
  221. return "image"
  222. }
  223. if isLikelyImagePath(path) {
  224. return "image"
  225. }
  226. return "text"
  227. }
  228. func isLikelyImagePlaceholder(sample string) bool {
  229. normalized := strings.ToLower(strings.TrimSpace(sample))
  230. if imagePlaceholderPattern.MatchString(normalized) {
  231. return true
  232. }
  233. if normalized == "image" || normalized == "img" || normalized == "photo" || normalized == "picture" {
  234. return true
  235. }
  236. return strings.Contains(normalized, " image image ")
  237. }
  238. func isLikelyImagePath(path string) bool {
  239. normalized := strings.ToLower(strings.TrimSpace(path))
  240. for _, hint := range imageLikePathHints {
  241. if strings.Contains(normalized, hint) {
  242. return true
  243. }
  244. }
  245. return false
  246. }