- Introduced new endpoints for contact discovery, including scanning, listing, and managing discovered contacts. - Implemented retry logic for handling missing DAV credentials during contact operations. - Added public share functionality for drive API, allowing users to manage public shares, including upload, delete, and rename operations. - Updated Nextcloud configuration to support public share links and improved error handling for public share permissions. - Enhanced logging and validation across contact and drive APIs for better error tracking and user feedback. - Added tests for new contact matching and ranking functionalities to ensure accuracy and reliability.
133 lines
5.0 KiB
Go
133 lines
5.0 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/llm"
|
|
"github.com/ultisuite/ulti-backend/internal/websearch"
|
|
)
|
|
|
|
const improveContactSystemPrompt = `Tu es un assistant qui nettoie, structure et enrichit une fiche contact existante.
|
|
Réponds UNIQUEMENT en JSON valide, sans markdown ni texte autour.
|
|
Corrige les problèmes de formatage : URLs avec slash final, accents/casse des noms, téléphones, adresses mal remplies ou incomplètes, champs vides inutiles.
|
|
Réorganise les informations de façon cohérente. Ne supprime pas d'emails ou téléphones valides.
|
|
Tu peux compléter poste, entreprise, site web, réseaux sociaux ou notes à partir des résultats de recherche en ligne fournis, uniquement si tu es raisonnablement confiant qu'ils concernent cette personne (profils LinkedIn, pages entreprise, etc.). En cas de doute ou d'homonymie, n'ajoute rien de nouveau.
|
|
Quand des résultats de recherche mentionnent des profils LinkedIn ou d'autres réseaux sociaux pertinents, ajoute-les dans social_profiles avec l'URL complète. Ne mets pas LinkedIn dans website : website = site personnel ou entreprise, social_profiles = profils sociaux.
|
|
Format attendu:
|
|
{
|
|
"first_name": "",
|
|
"last_name": "",
|
|
"company": "",
|
|
"department": "",
|
|
"job_title": "",
|
|
"emails": [{"value": "", "label": "work|home|other"}],
|
|
"phones": [{"value": "", "label": "work|mobile|home|other"}],
|
|
"addresses": [{"street": "", "city": "", "region": "", "postal_code": "", "country": "", "label": "work|home"}],
|
|
"website": "",
|
|
"social_profiles": [{"value": "https://...", "label": "linkedin|twitter|facebook|instagram|github|other"}],
|
|
"notes": ""
|
|
}`
|
|
|
|
type ImproveContactInput struct {
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
MiddleName string `json:"middle_name,omitempty"`
|
|
Company string `json:"company,omitempty"`
|
|
Department string `json:"department,omitempty"`
|
|
JobTitle string `json:"job_title,omitempty"`
|
|
Website string `json:"website,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Emails []FieldWithLabel `json:"emails,omitempty"`
|
|
Phones []FieldWithLabel `json:"phones,omitempty"`
|
|
Addresses []AddressField `json:"addresses,omitempty"`
|
|
SocialProfiles []FieldWithLabel `json:"social_profiles,omitempty"`
|
|
Birthday string `json:"birthday,omitempty"`
|
|
RawVCard string `json:"raw_vcard,omitempty"`
|
|
}
|
|
|
|
func contactCityFromAddresses(addresses []AddressField) string {
|
|
for _, a := range addresses {
|
|
if city := strings.TrimSpace(a.City); city != "" {
|
|
return city
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func buildImproveContactPrompt(input ImproveContactInput, searchSection string) string {
|
|
var b strings.Builder
|
|
b.WriteString("Fiche contact actuelle:\n")
|
|
payload, _ := json.MarshalIndent(input, "", " ")
|
|
b.Write(payload)
|
|
if searchSection != "" {
|
|
b.WriteString("\n\n")
|
|
b.WriteString(searchSection)
|
|
b.WriteString("\n\nÀ partir de ces résultats, complète social_profiles (LinkedIn en priorité, puis X/Twitter, Facebook, Instagram, GitHub) lorsque l'URL correspond clairement à cette personne.")
|
|
}
|
|
b.WriteString("\n\nPropose une version nettoyée, mieux structurée et enrichie si les sources le permettent.")
|
|
return b.String()
|
|
}
|
|
|
|
func (s *Service) fetchContactSearchResults(ctx context.Context, externalUserID string, input ImproveContactInput) string {
|
|
if s.websearch == nil {
|
|
return ""
|
|
}
|
|
searchSettings, err := s.loadSearchSettings(ctx, externalUserID)
|
|
if err != nil || !searchSettingsConfigured(searchSettings) {
|
|
return ""
|
|
}
|
|
provider, err := websearch.ResolveProvider(searchSettings)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
query := websearch.BuildContactSearchQuery(
|
|
input.FirstName,
|
|
input.LastName,
|
|
input.MiddleName,
|
|
input.Company,
|
|
input.JobTitle,
|
|
contactCityFromAddresses(input.Addresses),
|
|
)
|
|
if query == "" {
|
|
return ""
|
|
}
|
|
results, err := s.websearch.Search(ctx, provider, query, 5)
|
|
if err != nil {
|
|
s.logger.Warn("contact improve web search failed", "error", err)
|
|
return ""
|
|
}
|
|
return websearch.FormatResultsForPrompt(results)
|
|
}
|
|
|
|
func (s *Service) ImproveContact(ctx context.Context, externalUserID string, input ImproveContactInput) (*EnrichedContactData, error) {
|
|
if s.llm == nil {
|
|
return nil, ErrLLMNotConfigured
|
|
}
|
|
llmSettings, err := s.loadLLMSettings(ctx, externalUserID)
|
|
if err != nil || !llmSettingsHasProvider(llmSettings) {
|
|
return nil, ErrLLMNotConfigured
|
|
}
|
|
provider, model, err := llm.ResolveProvider(llmSettings, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
improveCtx, cancel := context.WithTimeout(ctx, llmEnrichTimeout)
|
|
defer cancel()
|
|
|
|
searchSection := s.fetchContactSearchResults(improveCtx, externalUserID, input)
|
|
prompt := buildImproveContactPrompt(input, searchSection)
|
|
raw, err := s.llm.Complete(improveCtx, provider, model, improveContactSystemPrompt, prompt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := parseEnrichedData(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse improved contact: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|