package discovery import ( "context" "encoding/json" "errors" "fmt" "github.com/jackc/pgx/v5" ) var ( ErrProfileNotFound = errors.New("profile not found") ErrNoSignatures = errors.New("no signatures available for enrichment") ErrAlreadyEnriched = errors.New("profile already enriched") ErrLLMNotConfigured = errors.New("llm provider not configured") ErrProfileNotSuggested = errors.New("profile is not available for enrichment") ) type ProfileEnrichResponse struct { ProfileID string `json:"profile_id"` EnrichmentStatus EnrichmentStatus `json:"enrichment_status"` } func (s *Service) StartProfileEnrichment(ctx context.Context, externalUserID, profileID string) (ProfileEnrichResponse, error) { sigs, err := s.loadProfileSignatures(ctx, profileID) if err != nil { return ProfileEnrichResponse{}, err } if len(sigs) == 0 { return ProfileEnrichResponse{}, ErrNoSignatures } llmSettings, _ := s.loadLLMSettings(ctx, externalUserID) if !llmSettingsHasProvider(llmSettings) { return ProfileEnrichResponse{}, ErrLLMNotConfigured } tag, err := s.db.Exec(ctx, ` UPDATE contact_discovered_profiles p SET enrichment_status = 'enriching', updated_at = NOW() FROM users u WHERE p.user_id = u.id AND u.external_id = $1 AND p.id = $2::uuid AND p.status = 'suggested' AND p.enrichment_status NOT IN ('enriched', 'enriching') `, externalUserID, profileID) if err != nil { return ProfileEnrichResponse{}, err } if tag.RowsAffected() == 0 { var status ProfileStatus var enrichStatus EnrichmentStatus err := s.db.QueryRow(ctx, ` SELECT p.status, p.enrichment_status FROM contact_discovered_profiles p JOIN users u ON p.user_id = u.id WHERE u.external_id = $1 AND p.id = $2::uuid `, externalUserID, profileID).Scan(&status, &enrichStatus) if err != nil { if err == pgx.ErrNoRows { return ProfileEnrichResponse{}, ErrProfileNotFound } return ProfileEnrichResponse{}, err } if status != ProfileSuggested { return ProfileEnrichResponse{}, ErrProfileNotSuggested } if enrichStatus == EnrichEnriched { return ProfileEnrichResponse{}, ErrAlreadyEnriched } if enrichStatus == EnrichEnriching { return ProfileEnrichResponse{ ProfileID: profileID, EnrichmentStatus: EnrichEnriching, }, nil } return ProfileEnrichResponse{}, fmt.Errorf("profile not enrichable") } ncUserID, bookID := s.resolveDiscoveryNCContext(ctx, externalUserID) go s.runProfileEnrichment(externalUserID, profileID, ncUserID, bookID) return ProfileEnrichResponse{ ProfileID: profileID, EnrichmentStatus: EnrichEnriching, }, nil } func (s *Service) runProfileEnrichment(externalUserID, profileID, ncUserID, bookID string) { ctx := context.Background() defer func() { if r := recover(); r != nil { s.logger.Error("profile enrichment panicked", "profile_id", profileID, "panic", r) _, _ = s.db.Exec(ctx, ` UPDATE contact_discovered_profiles SET enrichment_status = 'failed', updated_at = NOW() WHERE id = $1::uuid AND enrichment_status = 'enriching' `, profileID) } }() profile, err := s.getProfileByID(ctx, externalUserID, profileID) if err != nil { s.logger.Warn("profile enrichment load profile failed", "profile_id", profileID, "error", err) s.markProfileEnrichmentFailed(ctx, profileID) return } sigs, err := s.loadProfileSignatures(ctx, profileID) if err != nil || len(sigs) == 0 { s.markProfileEnrichmentFailed(ctx, profileID) return } llmSettings, err := s.loadLLMSettings(ctx, externalUserID) if err != nil || !llmSettingsHasProvider(llmSettings) { s.markProfileEnrichmentFailed(ctx, profileID) return } enriched, enrichErr := enrichWithLLMTimeout( ctx, s.llm, llmSettings, profile.PrimaryEmail, profile.DisplayName, sigs, llmEnrichTimeout, ) if enrichErr != nil { s.logger.Warn("profile enrichment llm failed", "profile_id", profileID, "error", enrichErr) s.markProfileEnrichmentFailed(ctx, profileID) return } rejections, _ := s.loadRejections(ctx, externalUserID) if err := s.applyEnrichmentResults(ctx, externalUserID, profileID, profile.PrimaryEmail, enriched, ncUserID, bookID, rejections); err != nil { s.logger.Warn("profile enrichment persist failed", "profile_id", profileID, "error", err) s.markProfileEnrichmentFailed(ctx, profileID) return } s.inferMissingCompanies(ctx, externalUserID, ncUserID, bookID) } func (s *Service) markProfileEnrichmentFailed(ctx context.Context, profileID string) { _, _ = s.db.Exec(ctx, ` UPDATE contact_discovered_profiles SET enrichment_status = 'failed', updated_at = NOW() WHERE id = $1::uuid AND enrichment_status = 'enriching' `, profileID) } func (s *Service) applyEnrichmentResults( ctx context.Context, externalUserID, profileID, email string, enriched *EnrichedContactData, ncUserID, bookID string, rejections map[string]bool, ) error { enrichedJSON, _ := json.Marshal(enriched) _, err := s.db.Exec(ctx, ` UPDATE contact_discovered_profiles SET enrichment_status = 'enriched', enriched_data = $2::jsonb, enriched_at = NOW(), updated_at = NOW() WHERE id = $1::uuid `, profileID, string(enrichedJSON)) if err != nil { return err } var existingContacts []ncContact if ncUserID != "" { existingContacts = s.loadNCContacts(ctx, ncUserID, bookID) } match := findExistingContact(existingContacts, email) var suggestions []Suggestion if match != nil { suggestions = enrichExistingContactSuggestions(profileID, match.UID, enriched, match) } else { suggestions = enrichedDataToSuggestions(externalUserID, profileID, enriched) } for _, sug := range suggestions { if match != nil && suggestionAlreadyOnContact(match, sug) { continue } rejKey := fmt.Sprintf("field:%s:%s:%s", profileID, sug.FieldPath, sug.SuggestedValue) if rejections[rejKey] { continue } _, _ = s.db.Exec(ctx, ` INSERT INTO contact_enrichment_suggestions ( user_id, profile_id, suggestion_type, field_path, suggested_value, suggested_label, confidence, status ) VALUES ( (SELECT id FROM users WHERE external_id = $1), $2::uuid, $3, $4, $5, $6, $7, 'pending' ) ON CONFLICT (user_id, profile_id, field_path, suggested_value) DO UPDATE SET status = CASE WHEN contact_enrichment_suggestions.status = 'rejected' THEN 'rejected' ELSE 'pending' END, confidence = EXCLUDED.confidence `, externalUserID, profileID, sug.SuggestionType, sug.FieldPath, sug.SuggestedValue, sug.SuggestedLabel, sug.Confidence) } return nil }