|
- package onboarding
-
- import (
- "context"
- "encoding/json"
- "fmt"
- "regexp"
- "sort"
- "strconv"
- "strings"
- "time"
-
- "qctextbuilder/internal/domain"
- "qctextbuilder/internal/qcclient"
- "qctextbuilder/internal/store"
- )
-
- var imagePlaceholderPattern = regexp.MustCompile(`^\[\s*(image|img|photo|picture)(\s+(image|img|photo|picture))*\s*\]$`)
-
- var imageLikePathHints = []string{
- "image",
- "img",
- "photo",
- "picture",
- "thumbnail",
- "gallery",
- "logo",
- "icon",
- "avatar",
- "background",
- "banner",
- }
-
- type Service struct {
- qc qcclient.Client
- templateStore store.TemplateStore
- manifestStore store.ManifestStore
- }
-
- type FieldPatch struct {
- Path string
- IsEnabled *bool
- IsRequiredByUs *bool
- DisplayLabel *string
- DisplayOrder *int
- WebsiteSection *string
- Notes *string
- }
-
- func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore) *Service {
- return &Service{
- qc: qc,
- templateStore: templateStore,
- manifestStore: manifestStore,
- }
- }
-
- func (s *Service) OnboardTemplate(ctx context.Context, templateID int64) (*domain.TemplateManifest, []domain.TemplateField, error) {
- template, err := s.templateStore.GetTemplateByID(ctx, templateID)
- if err != nil {
- return nil, nil, fmt.Errorf("get template: %w", err)
- }
- if !template.IsAITemplate {
- return nil, nil, fmt.Errorf("invalid template type: only ai template is allowed")
- }
-
- req := defaultDiscoveryRequest(templateID)
- data, raw, err := s.qc.GenerateContent(ctx, req)
- if err != nil {
- return nil, nil, fmt.Errorf("generate discovery content: %w", err)
- }
-
- manifestID := strconv.FormatInt(time.Now().UnixNano(), 10)
- now := time.Now().UTC()
- fields := flattenDiscovery(templateID, manifestID, data)
- flattened, _ := json.Marshal(fields)
- reqRaw, _ := json.Marshal(req)
-
- manifest := domain.TemplateManifest{
- ID: manifestID,
- TemplateID: templateID,
- Version: 1,
- Source: "generate-content",
- LanguageUsedDiscovery: "EN",
- DiscoveryPayloadJSON: reqRaw,
- DiscoveryResponseJSON: raw,
- FlattenedManifestJSON: flattened,
- IsActive: true,
- CreatedAt: now,
- UpdatedAt: now,
- }
-
- if err := s.manifestStore.CreateManifest(ctx, manifest, fields); err != nil {
- return nil, nil, fmt.Errorf("save manifest: %w", err)
- }
- if err := s.templateStore.SetTemplateManifestStatus(ctx, templateID, "reviewed", true); err != nil {
- return nil, nil, fmt.Errorf("update template status: %w", err)
- }
- return &manifest, fields, nil
- }
-
- func (s *Service) UpdateTemplateFields(ctx context.Context, templateID int64, manifestID string, patches []FieldPatch) (*domain.TemplateManifest, []domain.TemplateField, error) {
- template, err := s.templateStore.GetTemplateByID(ctx, templateID)
- if err != nil {
- return nil, nil, fmt.Errorf("get template: %w", err)
- }
- if !template.IsAITemplate {
- return nil, nil, fmt.Errorf("invalid template type: only ai template is allowed")
- }
-
- manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, templateID)
- if err != nil {
- return nil, nil, fmt.Errorf("get active manifest: %w", err)
- }
- if strings.TrimSpace(manifestID) != "" && manifest.ID != manifestID {
- return nil, nil, fmt.Errorf("manifest mismatch: active=%s requested=%s", manifest.ID, manifestID)
- }
-
- fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID)
- if err != nil {
- return nil, nil, fmt.Errorf("list fields: %w", err)
- }
-
- byPath := make(map[string]int, len(fields))
- for i := range fields {
- byPath[fields[i].Path] = i
- }
-
- for _, patch := range patches {
- path := strings.TrimSpace(patch.Path)
- if path == "" {
- return nil, nil, fmt.Errorf("field patch path is required")
- }
- idx, ok := byPath[path]
- if !ok {
- return nil, nil, fmt.Errorf("unknown field path: %s", path)
- }
-
- if patch.IsEnabled != nil {
- if *patch.IsEnabled && fields[idx].FieldKind != "text" {
- return nil, nil, fmt.Errorf("field %s cannot be enabled for kind %s", path, fields[idx].FieldKind)
- }
- fields[idx].IsEnabled = *patch.IsEnabled
- }
- if patch.IsRequiredByUs != nil {
- fields[idx].IsRequiredByUs = *patch.IsRequiredByUs
- }
- if patch.DisplayLabel != nil {
- fields[idx].DisplayLabel = strings.TrimSpace(*patch.DisplayLabel)
- }
- if patch.DisplayOrder != nil {
- fields[idx].DisplayOrder = *patch.DisplayOrder
- }
- if patch.WebsiteSection != nil {
- fields[idx].WebsiteSection = domain.NormalizeWebsiteSection(*patch.WebsiteSection)
- }
- if patch.Notes != nil {
- fields[idx].Notes = strings.TrimSpace(*patch.Notes)
- }
- }
-
- if err := s.manifestStore.UpdateFields(ctx, manifest.ID, fields); err != nil {
- return nil, nil, fmt.Errorf("update fields: %w", err)
- }
-
- sort.Slice(fields, func(i, j int) bool {
- if fields[i].DisplayOrder == fields[j].DisplayOrder {
- return fields[i].Path < fields[j].Path
- }
- return fields[i].DisplayOrder < fields[j].DisplayOrder
- })
- return manifest, fields, nil
- }
-
- func defaultDiscoveryRequest(templateID int64) qcclient.GenerateContentRequest {
- return qcclient.GenerateContentRequest{
- TemplateID: templateID,
- GlobalData: map[string]any{
- "companyName": "Discovery Company",
- "businessType": "dentist",
- "siteLanguage": "EN",
- "email": "discovery@example.com",
- "phone": "+41 44 000 00 00",
- "address": map[string]any{
- "line1": "Discovery Street 1",
- "line2": "",
- "city": "Zurich",
- "region": "ZH",
- "postalCode": "8000",
- "country": "CH",
- },
- },
- Empty: false,
- ToneOfVoice: "Professional",
- TargetAudience: "B2B",
- }
- }
-
- func flattenDiscovery(templateID int64, manifestID string, data qcclient.GenerateContentData) []domain.TemplateField {
- fields := make([]domain.TemplateField, 0)
- order := 0
- for section, kv := range data {
- for key, value := range kv {
- sample := fmt.Sprint(value)
- path := section + "." + key
- kind := detectFieldKind(path, sample)
- enabled := kind == "text"
-
- fields = append(fields, domain.TemplateField{
- ID: fmt.Sprintf("%s-%d", manifestID, order+1),
- TemplateID: templateID,
- ManifestID: manifestID,
- Section: section,
- WebsiteSection: domain.SuggestWebsiteSection(domain.TemplateField{
- Section: section,
- KeyName: key,
- Path: path,
- FieldKind: kind,
- SampleValue: sample,
- }),
- KeyName: key,
- Path: path,
- FieldKind: kind,
- SampleValue: sample,
- IsEnabled: enabled,
- DisplayLabel: path,
- DisplayOrder: order,
- Notes: "",
- })
- order++
- }
- }
- return fields
- }
-
- func detectFieldKind(path string, sample string) string {
- sampleTrim := strings.TrimSpace(sample)
- if sampleTrim == "" {
- return "unknown"
- }
-
- if strings.EqualFold(sampleTrim, "#IMAGE#") {
- return "image"
- }
- if isLikelyImagePlaceholder(sampleTrim) {
- return "image"
- }
- if isLikelyImagePath(path) {
- return "image"
- }
- return "text"
- }
-
- func isLikelyImagePlaceholder(sample string) bool {
- normalized := strings.ToLower(strings.TrimSpace(sample))
- if imagePlaceholderPattern.MatchString(normalized) {
- return true
- }
- if normalized == "image" || normalized == "img" || normalized == "photo" || normalized == "picture" {
- return true
- }
- return strings.Contains(normalized, " image image ")
- }
-
- func isLikelyImagePath(path string) bool {
- normalized := strings.ToLower(strings.TrimSpace(path))
- for _, hint := range imageLikePathHints {
- if strings.Contains(normalized, hint) {
- return true
- }
- }
- return false
- }
|