Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

338 řádky
9.3KB

  1. package onboarding
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log/slog"
  7. "regexp"
  8. "sort"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "qctextbuilder/internal/domain"
  13. "qctextbuilder/internal/mapping"
  14. "qctextbuilder/internal/qcclient"
  15. "qctextbuilder/internal/store"
  16. )
  17. var imagePlaceholderPattern = regexp.MustCompile(`^\[\s*(image|img|photo|picture)(\s+(image|img|photo|picture))*\s*\]$`)
  18. var imageLikePathHints = []string{
  19. "image",
  20. "img",
  21. "photo",
  22. "picture",
  23. "thumbnail",
  24. "gallery",
  25. "logo",
  26. "icon",
  27. "avatar",
  28. "background",
  29. "banner",
  30. }
  31. type Service struct {
  32. qc qcclient.Client
  33. templateStore store.TemplateStore
  34. manifestStore store.ManifestStore
  35. aiSlotMapper *mapping.AISlotMapper
  36. }
  37. type FieldPatch struct {
  38. Path string
  39. IsEnabled *bool
  40. IsRequiredByUs *bool
  41. DisplayLabel *string
  42. DisplayOrder *int
  43. WebsiteSection *string
  44. Notes *string
  45. SemanticSlot *string
  46. }
  47. func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore) *Service {
  48. return &Service{
  49. qc: qc,
  50. templateStore: templateStore,
  51. manifestStore: manifestStore,
  52. }
  53. }
  54. func (s *Service) WithAISlotMapper(mapper *mapping.AISlotMapper) *Service {
  55. s.aiSlotMapper = mapper
  56. return s
  57. }
  58. func (s *Service) OnboardTemplate(ctx context.Context, templateID int64) (*domain.TemplateManifest, []domain.TemplateField, error) {
  59. template, err := s.templateStore.GetTemplateByID(ctx, templateID)
  60. if err != nil {
  61. return nil, nil, fmt.Errorf("get template: %w", err)
  62. }
  63. if !template.IsAITemplate {
  64. return nil, nil, fmt.Errorf("invalid template type: only ai template is allowed")
  65. }
  66. req := defaultDiscoveryRequest(templateID)
  67. data, raw, err := s.qc.GenerateContent(ctx, req)
  68. if err != nil {
  69. return nil, nil, fmt.Errorf("generate discovery content: %w", err)
  70. }
  71. manifestID := strconv.FormatInt(time.Now().UnixNano(), 10)
  72. now := time.Now().UTC()
  73. fields := flattenDiscovery(templateID, manifestID, data)
  74. s.applyAISlotMapping(ctx, templateID, fields)
  75. flattened, _ := json.Marshal(fields)
  76. reqRaw, _ := json.Marshal(req)
  77. manifest := domain.TemplateManifest{
  78. ID: manifestID,
  79. TemplateID: templateID,
  80. Version: 1,
  81. Source: "generate-content",
  82. LanguageUsedDiscovery: "EN",
  83. DiscoveryPayloadJSON: reqRaw,
  84. DiscoveryResponseJSON: raw,
  85. FlattenedManifestJSON: flattened,
  86. IsActive: true,
  87. CreatedAt: now,
  88. UpdatedAt: now,
  89. }
  90. if err := s.manifestStore.CreateManifest(ctx, manifest, fields); err != nil {
  91. return nil, nil, fmt.Errorf("save manifest: %w", err)
  92. }
  93. if err := s.templateStore.SetTemplateManifestStatus(ctx, templateID, "reviewed", true); err != nil {
  94. return nil, nil, fmt.Errorf("update template status: %w", err)
  95. }
  96. return &manifest, fields, nil
  97. }
  98. func (s *Service) UpdateTemplateFields(ctx context.Context, templateID int64, manifestID string, patches []FieldPatch) (*domain.TemplateManifest, []domain.TemplateField, error) {
  99. template, err := s.templateStore.GetTemplateByID(ctx, templateID)
  100. if err != nil {
  101. return nil, nil, fmt.Errorf("get template: %w", err)
  102. }
  103. if !template.IsAITemplate {
  104. return nil, nil, fmt.Errorf("invalid template type: only ai template is allowed")
  105. }
  106. manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, templateID)
  107. if err != nil {
  108. return nil, nil, fmt.Errorf("get active manifest: %w", err)
  109. }
  110. if strings.TrimSpace(manifestID) != "" && manifest.ID != manifestID {
  111. return nil, nil, fmt.Errorf("manifest mismatch: active=%s requested=%s", manifest.ID, manifestID)
  112. }
  113. fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID)
  114. if err != nil {
  115. return nil, nil, fmt.Errorf("list fields: %w", err)
  116. }
  117. byPath := make(map[string]int, len(fields))
  118. for i := range fields {
  119. byPath[fields[i].Path] = i
  120. }
  121. for _, patch := range patches {
  122. path := strings.TrimSpace(patch.Path)
  123. if path == "" {
  124. return nil, nil, fmt.Errorf("field patch path is required")
  125. }
  126. idx, ok := byPath[path]
  127. if !ok {
  128. return nil, nil, fmt.Errorf("unknown field path: %s", path)
  129. }
  130. if patch.IsEnabled != nil {
  131. if *patch.IsEnabled && fields[idx].FieldKind != "text" {
  132. return nil, nil, fmt.Errorf("field %s cannot be enabled for kind %s", path, fields[idx].FieldKind)
  133. }
  134. fields[idx].IsEnabled = *patch.IsEnabled
  135. }
  136. if patch.IsRequiredByUs != nil {
  137. fields[idx].IsRequiredByUs = *patch.IsRequiredByUs
  138. }
  139. if patch.DisplayLabel != nil {
  140. fields[idx].DisplayLabel = strings.TrimSpace(*patch.DisplayLabel)
  141. }
  142. if patch.DisplayOrder != nil {
  143. fields[idx].DisplayOrder = *patch.DisplayOrder
  144. }
  145. if patch.WebsiteSection != nil {
  146. fields[idx].WebsiteSection = domain.NormalizeWebsiteSection(*patch.WebsiteSection)
  147. }
  148. if patch.Notes != nil {
  149. fields[idx].Notes = strings.TrimSpace(*patch.Notes)
  150. }
  151. if patch.SemanticSlot != nil {
  152. newSlot := strings.TrimSpace(*patch.SemanticSlot)
  153. if newSlot != fields[idx].SemanticSlot {
  154. fields[idx].SemanticSlot = newSlot
  155. if newSlot == "" {
  156. fields[idx].MappingSource = ""
  157. fields[idx].MappingConfidence = 0
  158. } else {
  159. fields[idx].MappingSource = domain.MappingSourceManual
  160. fields[idx].MappingConfidence = 1.0
  161. }
  162. }
  163. }
  164. }
  165. if err := s.manifestStore.UpdateFields(ctx, manifest.ID, fields); err != nil {
  166. return nil, nil, fmt.Errorf("update fields: %w", err)
  167. }
  168. sort.Slice(fields, func(i, j int) bool {
  169. if fields[i].DisplayOrder == fields[j].DisplayOrder {
  170. return fields[i].Path < fields[j].Path
  171. }
  172. return fields[i].DisplayOrder < fields[j].DisplayOrder
  173. })
  174. return manifest, fields, nil
  175. }
  176. func defaultDiscoveryRequest(templateID int64) qcclient.GenerateContentRequest {
  177. return qcclient.GenerateContentRequest{
  178. TemplateID: templateID,
  179. GlobalData: map[string]any{
  180. "companyName": "Discovery Company",
  181. "businessType": "dentist",
  182. "siteLanguage": "EN",
  183. "email": "discovery@example.com",
  184. "phone": "+41 44 000 00 00",
  185. "address": map[string]any{
  186. "line1": "Discovery Street 1",
  187. "line2": "",
  188. "city": "Zurich",
  189. "region": "ZH",
  190. "postalCode": "8000",
  191. "country": "CH",
  192. },
  193. },
  194. Empty: false,
  195. ToneOfVoice: "Professional",
  196. TargetAudience: "B2B",
  197. }
  198. }
  199. func flattenDiscovery(templateID int64, manifestID string, data qcclient.GenerateContentData) []domain.TemplateField {
  200. fields := make([]domain.TemplateField, 0)
  201. order := 0
  202. for section, kv := range data {
  203. for key, value := range kv {
  204. sample := fmt.Sprint(value)
  205. path := section + "." + key
  206. kind := detectFieldKind(path, sample)
  207. enabled := kind == "text"
  208. fields = append(fields, domain.TemplateField{
  209. ID: fmt.Sprintf("%s-%d", manifestID, order+1),
  210. TemplateID: templateID,
  211. ManifestID: manifestID,
  212. Section: section,
  213. WebsiteSection: domain.SuggestWebsiteSection(domain.TemplateField{
  214. Section: section,
  215. KeyName: key,
  216. Path: path,
  217. FieldKind: kind,
  218. SampleValue: sample,
  219. }),
  220. KeyName: key,
  221. Path: path,
  222. FieldKind: kind,
  223. SampleValue: sample,
  224. IsEnabled: enabled,
  225. DisplayLabel: path,
  226. DisplayOrder: order,
  227. Notes: "",
  228. })
  229. order++
  230. }
  231. }
  232. return fields
  233. }
  234. func detectFieldKind(path string, sample string) string {
  235. sampleTrim := strings.TrimSpace(sample)
  236. if sampleTrim == "" {
  237. return "unknown"
  238. }
  239. if strings.EqualFold(sampleTrim, "#IMAGE#") {
  240. return "image"
  241. }
  242. if isLikelyImagePlaceholder(sampleTrim) {
  243. return "image"
  244. }
  245. if isLikelyImagePath(path) {
  246. return "image"
  247. }
  248. return "text"
  249. }
  250. func isLikelyImagePlaceholder(sample string) bool {
  251. normalized := strings.ToLower(strings.TrimSpace(sample))
  252. if imagePlaceholderPattern.MatchString(normalized) {
  253. return true
  254. }
  255. if normalized == "image" || normalized == "img" || normalized == "photo" || normalized == "picture" {
  256. return true
  257. }
  258. return strings.Contains(normalized, " image image ")
  259. }
  260. // applyAISlotMapping runs the AI slot mapper (if configured) and writes the
  261. // resulting semantic slot + source onto the fields in-place. Failures are
  262. // logged and ignored — onboarding still succeeds, and rule-based mapping will
  263. // take over at build time.
  264. func (s *Service) applyAISlotMapping(ctx context.Context, templateID int64, fields []domain.TemplateField) {
  265. if s == nil || s.aiSlotMapper == nil || len(fields) == 0 {
  266. return
  267. }
  268. assignments, err := s.aiSlotMapper.MapFields(ctx, templateID, fields)
  269. if err != nil {
  270. slog.WarnContext(ctx, "ai slot mapping skipped",
  271. "component", "onboarding",
  272. "template_id", templateID,
  273. "error", err.Error(),
  274. )
  275. return
  276. }
  277. if len(assignments) == 0 {
  278. return
  279. }
  280. bySlot := make(map[string]mapping.AISlotAssignment, len(assignments))
  281. for _, a := range assignments {
  282. bySlot[a.FieldPath] = a
  283. }
  284. for i := range fields {
  285. assignment, ok := bySlot[strings.TrimSpace(fields[i].Path)]
  286. if !ok {
  287. continue
  288. }
  289. fields[i].SemanticSlot = assignment.Slot
  290. fields[i].MappingSource = domain.MappingSourceAI
  291. fields[i].MappingConfidence = assignment.Confidence
  292. }
  293. slog.InfoContext(ctx, "ai slot mapping applied",
  294. "component", "onboarding",
  295. "template_id", templateID,
  296. "mapped_count", len(assignments),
  297. "total_fields", len(fields),
  298. )
  299. }
  300. func isLikelyImagePath(path string) bool {
  301. normalized := strings.ToLower(strings.TrimSpace(path))
  302. for _, hint := range imageLikePathHints {
  303. if strings.Contains(normalized, hint) {
  304. return true
  305. }
  306. }
  307. return false
  308. }