mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-30 08:15:41 +00:00
feat: send nothing to TypeSafe when an import's header row holds an address, date or number, skip placeholder Column N headers, accept only the options each column was offered, never infer Subscribed, Categories or Email, prefer an exact existing field name before a folded match on the server as the client does, compute value kinds once per preview, and make Enter in the empty field search a no-op
This commit is contained in:
@@ -83,11 +83,12 @@ func (s *contactService) ImportPreview(ctx context.Context, orgID uuid.UUID, r i
|
||||
// placed, so the mapper can ask for a second look at those.
|
||||
func (s *contactService) SuggestImportMapping(ctx context.Context, orgID uuid.UUID, headers []string, sample [][]string) ([]models.ContactImportColumnMapping, []int) {
|
||||
keys := s.existingCustomFieldKeys(ctx, orgID)
|
||||
suggested := SuggestMapping(headers, sample, keys)
|
||||
shapes := importmap.Shapes(len(headers), sample)
|
||||
suggested := suggestMapping(headers, sample, shapes, keys)
|
||||
if s.columnJudge == nil {
|
||||
return suggested, nil
|
||||
}
|
||||
return importmap.Infer(ctx, s.columnJudge, suggested, headers, importmap.Shapes(len(headers), sample), keys)
|
||||
return importmap.Infer(ctx, s.columnJudge, suggested, headers, shapes, keys)
|
||||
}
|
||||
|
||||
// existingCustomFieldKeys is the workspace's custom-field keys for the
|
||||
@@ -907,6 +908,10 @@ func padRow(row []string, n int) []string {
|
||||
// better than inventing a custom-field key the user didn't ask for. Every
|
||||
// importer that shows a column mapper calls this, so they suggest alike.
|
||||
func SuggestMapping(headers []string, sample [][]string, existingKeys []string) []models.ContactImportColumnMapping {
|
||||
return suggestMapping(headers, sample, importmap.Shapes(len(headers), sample), existingKeys)
|
||||
}
|
||||
|
||||
func suggestMapping(headers []string, sample [][]string, shapes []importmap.Shape, existingKeys []string) []models.ContactImportColumnMapping {
|
||||
out := make([]models.ContactImportColumnMapping, len(headers))
|
||||
for i, h := range headers {
|
||||
out[i] = guessTarget(i, h)
|
||||
@@ -940,22 +945,21 @@ func SuggestMapping(headers []string, sample [][]string, existingKeys []string)
|
||||
out[i] = models.ContactImportColumnMapping{Index: i, Target: models.ContactImportTargetVerificationStatus, VerificationProvider: provider}
|
||||
}
|
||||
matchExistingCustomFields(out, headers, existingKeys)
|
||||
matchEmailByValues(out, headers, sample)
|
||||
matchEmailByValues(out, shapes)
|
||||
return out
|
||||
}
|
||||
|
||||
// matchEmailByValues maps the first column of addresses to Email when no
|
||||
// header named it, so a file with "Work contact" or no header row at all
|
||||
// still has the one column an import cannot go without.
|
||||
func matchEmailByValues(out []models.ContactImportColumnMapping, headers []string, sample [][]string) {
|
||||
func matchEmailByValues(out []models.ContactImportColumnMapping, shapes []importmap.Shape) {
|
||||
for _, m := range out {
|
||||
if m.Target == models.ContactImportTargetEmail {
|
||||
return
|
||||
}
|
||||
}
|
||||
shapes := importmap.Shapes(len(headers), sample)
|
||||
for i := range out {
|
||||
if out[i].Target == models.ContactImportTargetIgnore && shapes[i] == importmap.ShapeEmail {
|
||||
if i < len(shapes) && out[i].Target == models.ContactImportTargetIgnore && shapes[i] == importmap.ShapeEmail {
|
||||
out[i] = models.ContactImportColumnMapping{Index: i, Target: models.ContactImportTargetEmail}
|
||||
return
|
||||
}
|
||||
@@ -963,15 +967,18 @@ func matchEmailByValues(out []models.ContactImportColumnMapping, headers []strin
|
||||
}
|
||||
|
||||
// matchExistingCustomFields maps each still-ignored column whose header names
|
||||
// an existing custom field, ignoring case and separators, onto that field's
|
||||
// stored spelling, so "industry" in a file lands on "Industry" instead of
|
||||
// starting a second field. Each field is claimed by one column at most.
|
||||
// an existing custom field onto that field's stored spelling: the exact name
|
||||
// first, then one differing only in case or separators, so "industry" in a
|
||||
// file lands on "Industry" instead of starting a second field. Each field is
|
||||
// claimed by one column at most. Mirrored by matchExistingKey in the web app.
|
||||
func matchExistingCustomFields(out []models.ContactImportColumnMapping, headers, existingKeys []string) {
|
||||
if len(existingKeys) == 0 {
|
||||
return
|
||||
}
|
||||
exact := make(map[string]bool, len(existingKeys))
|
||||
byFold := make(map[string]string, len(existingKeys))
|
||||
for _, k := range existingKeys {
|
||||
exact[k] = true
|
||||
f := FoldCustomFieldKey(k)
|
||||
if _, taken := byFold[f]; f != "" && !taken {
|
||||
byFold[f] = k
|
||||
@@ -982,12 +989,17 @@ func matchExistingCustomFields(out []models.ContactImportColumnMapping, headers,
|
||||
if out[i].Target != models.ContactImportTargetIgnore {
|
||||
continue
|
||||
}
|
||||
f := FoldCustomFieldKey(h)
|
||||
key, ok := byFold[f]
|
||||
if !ok || claimed[f] {
|
||||
key := utils.NormalizeJSONKey(h)
|
||||
if !exact[key] {
|
||||
var ok bool
|
||||
if key, ok = byFold[FoldCustomFieldKey(h)]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if claimed[key] {
|
||||
continue
|
||||
}
|
||||
claimed[f] = true
|
||||
claimed[key] = true
|
||||
out[i] = models.ContactImportColumnMapping{Index: i, Target: models.ContactImportTargetCustom, CustomKey: key}
|
||||
}
|
||||
}
|
||||
@@ -998,7 +1010,7 @@ func matchExistingCustomFields(out []models.ContactImportColumnMapping, headers,
|
||||
func FoldCustomFieldKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,15 @@ func TestSuggestMappingMatchesExistingCustomFields(t *testing.T) {
|
||||
|
||||
want := []models.ContactImportColumnMapping{
|
||||
{Index: 0, Target: models.ContactImportTargetEmail},
|
||||
// The stored spelling wins, and the most used of two spellings.
|
||||
{Index: 1, Target: models.ContactImportTargetCustom, CustomKey: "Industry"},
|
||||
// An exact name wins over a more used spelling of it.
|
||||
{Index: 1, Target: models.ContactImportTargetCustom, CustomKey: "industry"},
|
||||
// Otherwise the stored spelling, the most used of several.
|
||||
{Index: 2, Target: models.ContactImportTargetCustom, CustomKey: "company_url"},
|
||||
// No field by that name: left for the user, never invented.
|
||||
{Index: 3, Target: models.ContactImportTargetIgnore},
|
||||
// A standard field outranks a custom field of the same name.
|
||||
{Index: 4, Target: models.ContactImportTargetCompany},
|
||||
// One column per field; a second spelling of it stays ignored.
|
||||
{Index: 5, Target: models.ContactImportTargetIgnore},
|
||||
{Index: 5, Target: models.ContactImportTargetCustom, CustomKey: "Industry"},
|
||||
{Index: 6, Target: models.ContactImportTargetIgnore},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
@@ -35,6 +35,13 @@ func TestSuggestMappingMatchesExistingCustomFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestMappingGivesEachFieldOneColumn(t *testing.T) {
|
||||
got := SuggestMapping([]string{"Email", "Industry", "INDUSTRY"}, nil, []string{"Industry"})
|
||||
if got[1].CustomKey != "Industry" || got[2].Target != models.ContactImportTargetIgnore {
|
||||
t.Fatalf("got %+v, want the second spelling left ignored", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestMappingKeepsVerificationAheadOfCustomFields(t *testing.T) {
|
||||
headers := []string{"email", "ZeroBounce Status"}
|
||||
got := SuggestMapping(headers, [][]string{{"a@x.com", "valid"}}, []string{"ZeroBounce Status"})
|
||||
@@ -59,6 +66,7 @@ func TestFoldCustomFieldKey(t *testing.T) {
|
||||
"Revenue ($)": "revenue",
|
||||
"###": "",
|
||||
"Straße Nummer2": "straßenummer2",
|
||||
"Area m²": "aream²",
|
||||
} {
|
||||
if got := FoldCustomFieldKey(in); got != want {
|
||||
t.Errorf("FoldCustomFieldKey(%q) = %q, want %q", in, got, want)
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
// Package importmap asks TypeSafe which field an import column holds when its
|
||||
// header matched nothing, so "Job Title", "Firmenname" or "Sector" lands on the
|
||||
// right field before anyone opens a dropdown.
|
||||
//
|
||||
// THIS FILE IS THE POLICY: every question, option and threshold is here. It
|
||||
// follows the rules the inbox tagger learned the hard way:
|
||||
//
|
||||
// 1. One call per file. Every unmatched column is its own question over one
|
||||
// state, so the headers are ingested once.
|
||||
// 2. The model answers, code decides. Resolve applies the confidence floor
|
||||
// and gives each field to one column at most.
|
||||
// 3. Never ask what is already known. Columns the header aliases, the
|
||||
// verification vocabulary, an existing custom field or the values
|
||||
// themselves already decided are facts, and are never sent as questions.
|
||||
//
|
||||
// No cell value leaves the instance. The state is the headers, the workspace's
|
||||
// custom field names, and the kind of value each column holds (Shape).
|
||||
// Package importmap asks TypeSafe which field an import column holds when no
|
||||
// header rule placed it. The model answers, resolve decides, and the state
|
||||
// carries headers, value kinds and field names, never a cell.
|
||||
package importmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -53,22 +42,25 @@ const (
|
||||
// optionNone is the answer for a column that holds none of the fields offered.
|
||||
const optionNone = "none"
|
||||
|
||||
// standardOptions are the fields the model may pick, with what each holds.
|
||||
// Verification status is never offered: it is recognised from the values'
|
||||
// vocabulary, which the model is not shown.
|
||||
// standardOptions are the standard fields the model may pick, and the value
|
||||
// kinds each is offered for. Email is found by its values, and Subscribed and
|
||||
// Categories stay the user's call: a wrong guess there unsubscribes people or
|
||||
// mints categories from cell values.
|
||||
var standardOptions = []struct {
|
||||
target models.ContactImportColumnTarget
|
||||
desc string
|
||||
holds []Shape
|
||||
}{
|
||||
{models.ContactImportTargetEmail, "The contact's email address"},
|
||||
{models.ContactImportTargetFirstName, "The contact's first name, or given name"},
|
||||
{models.ContactImportTargetLastName, "The contact's last name, surname, or family name"},
|
||||
{models.ContactImportTargetCompany, "The name of the company or organization the contact works for"},
|
||||
{models.ContactImportTargetPhone, "The contact's phone or mobile number"},
|
||||
{models.ContactImportTargetSubscribed, "Whether the contact agreed to receive email, as yes or no"},
|
||||
{models.ContactImportTargetCategories, "Tags, labels, lists, or segments the contact belongs to"},
|
||||
{models.ContactImportTargetFirstName, "The contact's first name, or given name", []Shape{ShapeText}},
|
||||
{models.ContactImportTargetLastName, "The contact's last name, surname, or family name", []Shape{ShapeText}},
|
||||
{models.ContactImportTargetCompany, "The name of the company or organization the contact works for", []Shape{ShapeText}},
|
||||
{models.ContactImportTargetPhone, "The contact's phone or mobile number", []Shape{ShapePhone, ShapeNumber, ShapeMixed}},
|
||||
}
|
||||
|
||||
// placeholderRe is the name a column gets when its header cell is blank or
|
||||
// the file has no header row; it says nothing about the column.
|
||||
var placeholderRe = regexp.MustCompile(`^Column [0-9]+$`)
|
||||
|
||||
// Column is one column as the model sees it.
|
||||
type Column struct {
|
||||
Header string `json:"header"`
|
||||
@@ -98,23 +90,35 @@ func Infer(
|
||||
shapes []Shape,
|
||||
existingKeys []string,
|
||||
) ([]models.ContactImportColumnMapping, []int) {
|
||||
if asker == nil {
|
||||
// A header row holding an address, a date or a number is a data row, so
|
||||
// sending it would send a contact.
|
||||
if asker == nil || headerRowIsData(headers) {
|
||||
return mapping, nil
|
||||
}
|
||||
taken := takenTargets(mapping)
|
||||
options, criteria := buildOptions(taken, existingKeys)
|
||||
options := buildOptions(taken, existingKeys)
|
||||
|
||||
st := state{Columns: make([]Column, len(headers)), CustomFields: capKeys(existingKeys)}
|
||||
questions := map[string]typesafe.Question{}
|
||||
for i := range headers {
|
||||
col := Column{Header: safeHeader(i, headers[i]), Holds: shapeAt(shapes, i)}
|
||||
offered := map[int]map[string]bool{}
|
||||
for i, h := range headers {
|
||||
col := Column{Header: capRunes(strings.TrimSpace(h), headerRunes), Holds: shapeAt(shapes, i)}
|
||||
st.Columns[i] = col
|
||||
if len(questions) >= MaxQuestions || i >= len(mapping) || mapping[i].Target != models.ContactImportTargetIgnore || col.Holds == ShapeEmpty {
|
||||
if len(questions) >= MaxQuestions || i >= len(mapping) || mapping[i].Target != models.ContactImportTargetIgnore ||
|
||||
col.Holds == ShapeEmpty || col.Header == "" || placeholderRe.MatchString(col.Header) {
|
||||
continue
|
||||
}
|
||||
criteria := columnCriteria(options, col.Holds)
|
||||
if len(criteria) <= 1 {
|
||||
continue
|
||||
}
|
||||
offered[i] = make(map[string]bool, len(criteria))
|
||||
for id := range criteria {
|
||||
offered[i][id] = true
|
||||
}
|
||||
questions[questionID(i)] = typesafe.Choice(
|
||||
"Which contact field does the column headed \""+col.Header+"\" hold? Its values are "+string(col.Holds)+".",
|
||||
columnCriteria(criteria, col.Holds),
|
||||
criteria,
|
||||
)
|
||||
}
|
||||
if len(questions) == 0 {
|
||||
@@ -127,16 +131,18 @@ func Infer(
|
||||
if err != nil || resp == nil {
|
||||
return mapping, nil
|
||||
}
|
||||
return Resolve(mapping, resp.Answers, options, taken)
|
||||
return resolve(mapping, resp.Answers, options, offered, taken)
|
||||
}
|
||||
|
||||
// Resolve applies the answers: a confident choice of a field nobody fills yet
|
||||
// maps its column, and when two columns want one field the more confident
|
||||
// wins. It never touches a column the suggester already mapped.
|
||||
func Resolve(
|
||||
// resolve applies the answers: a confident choice, among the options that
|
||||
// column was offered, of a field nobody fills yet maps its column, and when
|
||||
// two columns want one field the more confident wins. It never touches a
|
||||
// column the suggester already mapped.
|
||||
func resolve(
|
||||
mapping []models.ContactImportColumnMapping,
|
||||
answers map[string]typesafe.Answer,
|
||||
options map[string]option,
|
||||
options map[string]offer,
|
||||
offered map[int]map[string]bool,
|
||||
taken map[string]bool,
|
||||
) ([]models.ContactImportColumnMapping, []int) {
|
||||
type candidate struct {
|
||||
@@ -146,19 +152,19 @@ func Resolve(
|
||||
conf float64
|
||||
}
|
||||
var cands []candidate
|
||||
for id, a := range answers {
|
||||
col, ok := columnOf(id)
|
||||
for qid, a := range answers {
|
||||
col, ok := columnOf(qid)
|
||||
if !ok || col >= len(mapping) || mapping[col].Target != models.ContactImportTargetIgnore {
|
||||
continue
|
||||
}
|
||||
if a.Confidence < ConfFloor || a.Choice == optionNone {
|
||||
if a.Confidence < ConfFloor || a.Choice == optionNone || !offered[col][a.Choice] {
|
||||
continue
|
||||
}
|
||||
opt, ok := options[a.Choice]
|
||||
o, ok := options[a.Choice]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, candidate{col: col, opt: opt, id: identity(opt), conf: a.Confidence})
|
||||
cands = append(cands, candidate{col: col, opt: o.option, id: identity(o.option), conf: a.Confidence})
|
||||
}
|
||||
sort.Slice(cands, func(i, j int) bool {
|
||||
if cands[i].conf != cands[j].conf {
|
||||
@@ -174,12 +180,10 @@ func Resolve(
|
||||
}
|
||||
var inferred []int
|
||||
for _, c := range cands {
|
||||
if c.id != "" && claimed[c.id] {
|
||||
if claimed[c.id] {
|
||||
continue
|
||||
}
|
||||
if c.id != "" {
|
||||
claimed[c.id] = true
|
||||
}
|
||||
claimed[c.id] = true
|
||||
out[c.col] = models.ContactImportColumnMapping{Index: c.col, Target: c.opt.target, CustomKey: c.opt.customKey}
|
||||
inferred = append(inferred, c.col)
|
||||
}
|
||||
@@ -187,49 +191,58 @@ func Resolve(
|
||||
return out, inferred
|
||||
}
|
||||
|
||||
// offer is one option with what it says to the model and which value kinds
|
||||
// it is offered for (nil: every kind).
|
||||
type offer struct {
|
||||
option
|
||||
desc string
|
||||
holds []Shape
|
||||
}
|
||||
|
||||
// buildOptions lists the fields still free, under short ids so no field name
|
||||
// has to survive as an option key. criteria holds each id's description.
|
||||
func buildOptions(taken map[string]bool, existingKeys []string) (map[string]option, map[string]string) {
|
||||
options := map[string]option{}
|
||||
criteria := map[string]string{}
|
||||
// has to survive as an option key.
|
||||
func buildOptions(taken map[string]bool, existingKeys []string) map[string]offer {
|
||||
options := map[string]offer{}
|
||||
for _, s := range standardOptions {
|
||||
o := option{target: s.target}
|
||||
if taken[identity(o)] {
|
||||
continue
|
||||
if !taken[identity(o)] {
|
||||
options[string(s.target)] = offer{option: o, desc: s.desc, holds: s.holds}
|
||||
}
|
||||
id := string(s.target)
|
||||
options[id] = o
|
||||
criteria[id] = s.desc
|
||||
}
|
||||
for i, k := range capKeys(existingKeys) {
|
||||
o := option{target: models.ContactImportTargetCustom, customKey: k}
|
||||
if taken[identity(o)] {
|
||||
continue
|
||||
if !taken[identity(o)] {
|
||||
options["field_"+strconv.Itoa(i+1)] = offer{option: o, desc: "The workspace's existing custom field named \"" + k + "\""}
|
||||
}
|
||||
id := "field_" + strconv.Itoa(i+1)
|
||||
options[id] = o
|
||||
criteria[id] = "The workspace's existing custom field named \"" + k + "\""
|
||||
}
|
||||
criteria[optionNone] = "None of these: the column holds something else"
|
||||
return options, criteria
|
||||
return options
|
||||
}
|
||||
|
||||
// columnCriteria drops the options a column's values rule out, so a column of
|
||||
// URLs is never offered Subscribed.
|
||||
func columnCriteria(all map[string]string, holds Shape) map[string]string {
|
||||
out := make(map[string]string, len(all))
|
||||
for id, desc := range all {
|
||||
if id == string(models.ContactImportTargetSubscribed) && holds != ShapeYesNo {
|
||||
continue
|
||||
// columnCriteria is what one column is asked to choose from: the options its
|
||||
// value kind allows, and none.
|
||||
func columnCriteria(options map[string]offer, holds Shape) map[string]string {
|
||||
out := map[string]string{optionNone: "None of these: the column holds something else"}
|
||||
for id, o := range options {
|
||||
if o.holds == nil || slices.Contains(o.holds, holds) {
|
||||
out[id] = o.desc
|
||||
}
|
||||
if id == string(models.ContactImportTargetEmail) && holds != ShapeEmail {
|
||||
continue
|
||||
}
|
||||
out[id] = desc
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// headerRowIsData reports whether any header cell is shaped like a value
|
||||
// rather than a name.
|
||||
func headerRowIsData(headers []string) bool {
|
||||
for _, h := range headers {
|
||||
switch ShapeOf([]string{h}) {
|
||||
case ShapeText, ShapeLongText, ShapeEmpty:
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// takenTargets is every destination the suggestion already fills.
|
||||
func takenTargets(mapping []models.ContactImportColumnMapping) map[string]bool {
|
||||
taken := map[string]bool{}
|
||||
@@ -242,11 +255,11 @@ func takenTargets(mapping []models.ContactImportColumnMapping) map[string]bool {
|
||||
return taken
|
||||
}
|
||||
|
||||
// identity names where an option writes; "" for targets that take any number
|
||||
// of columns (categories) or none (ignore).
|
||||
// identity names where an option writes. Every target Infer can pick holds
|
||||
// one column's value.
|
||||
func identity(o option) string {
|
||||
switch o.target {
|
||||
case models.ContactImportTargetIgnore, models.ContactImportTargetCategories, "":
|
||||
case models.ContactImportTargetIgnore, "":
|
||||
return ""
|
||||
case models.ContactImportTargetCustom:
|
||||
return "custom:" + o.customKey
|
||||
@@ -264,16 +277,11 @@ func capKeys(keys []string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// safeHeader is the header as sent. A header row that is really a data row
|
||||
// ("dana@acme.com") is never sent: the column goes by its number instead.
|
||||
func safeHeader(i int, h string) string {
|
||||
if ShapeOf([]string{h}) != ShapeText {
|
||||
return "Column " + strconv.Itoa(i+1)
|
||||
func capRunes(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
if utf8.RuneCountInString(h) > headerRunes {
|
||||
h = string([]rune(h)[:headerRunes])
|
||||
}
|
||||
return h
|
||||
return string([]rune(s)[:n])
|
||||
}
|
||||
|
||||
func shapeAt(shapes []Shape, i int) Shape {
|
||||
|
||||
@@ -49,10 +49,10 @@ func optionFor(t *testing.T, q typesafe.Question, fragment string) string {
|
||||
}
|
||||
|
||||
func TestInferAsksOnlyAboutUnmappedColumnsAndSendsNoValues(t *testing.T) {
|
||||
headers := []string{"Email", "Job Title", "Firmenname", "Blank", "dana@acme.com"}
|
||||
headers := []string{"Email", "Job Title", "Firmenname", "Blank", "Column 5"}
|
||||
sample := [][]string{
|
||||
{"dana@acme.com", "Head of Sales", "Acme BV", "", "lee@beta.io"},
|
||||
{"lee@beta.io", "CTO", "Beta GmbH", "", "kim@gamma.io"},
|
||||
{"dana@acme.com", "Head of Sales", "Acme BV", "", "Warm"},
|
||||
{"lee@beta.io", "CTO", "Beta GmbH", "", "Cold"},
|
||||
}
|
||||
mapping := ignored(len(headers))
|
||||
mapping[0].Target = models.ContactImportTargetEmail
|
||||
@@ -63,34 +63,44 @@ func TestInferAsksOnlyAboutUnmappedColumnsAndSendsNoValues(t *testing.T) {
|
||||
if f.calls != 1 {
|
||||
t.Fatalf("want one call per file, got %d", f.calls)
|
||||
}
|
||||
for _, id := range []string{"column_2", "column_3", "column_5"} {
|
||||
for _, id := range []string{"column_2", "column_3"} {
|
||||
if _, ok := f.questions[id]; !ok {
|
||||
t.Errorf("missing question %s", id)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"column_1", "column_4"} {
|
||||
// Mapped, empty, and a placeholder name that says nothing about the column.
|
||||
for _, id := range []string{"column_1", "column_4", "column_5"} {
|
||||
if _, ok := f.questions[id]; ok {
|
||||
t.Errorf("asked about %s, which is mapped or empty", id)
|
||||
t.Errorf("asked about %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(f.state)
|
||||
for _, cell := range []string{"Head of Sales", "Acme BV", "dana@acme.com", "lee@beta.io", "CTO"} {
|
||||
for _, cell := range []string{"Head of Sales", "Acme BV", "dana@acme.com", "lee@beta.io", "CTO", "Warm"} {
|
||||
if strings.Contains(string(raw), cell) {
|
||||
t.Errorf("state carries the cell value %q: %s", cell, raw)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(raw), `"Column 5"`) {
|
||||
t.Errorf("a header that is really data must go by its number: %s", raw)
|
||||
}
|
||||
if strings.Contains(f.questions["column_5"].Instructions, "@") {
|
||||
t.Errorf("the question repeats a data-shaped header: %s", f.questions["column_5"].Instructions)
|
||||
}
|
||||
|
||||
func TestInferSendsNothingWhenTheHeaderRowIsData(t *testing.T) {
|
||||
// A sheet with no header row: its first contact became the headers.
|
||||
for _, headers := range [][]string{
|
||||
{"dana@acme.com", "Dana", "Reyes", "Acme BV"},
|
||||
{"Email", "Dana", "2026-09-05", "Acme BV"},
|
||||
} {
|
||||
f := &fakeAsker{}
|
||||
got, inferred := Infer(context.Background(), f, ignored(len(headers)), headers,
|
||||
[]Shape{ShapeEmail, ShapeText, ShapeText, ShapeText}, nil)
|
||||
if f.calls != 0 || inferred != nil || got[1].Target != models.ContactImportTargetIgnore {
|
||||
t.Fatalf("header row %q reached the judge", headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
|
||||
headers := []string{"Email", "Job Title", "Firmenname", "Org", "Opt-in", "Notes"}
|
||||
sample := [][]string{{"a@x.com", "CEO", "Acme", "Acme", "yes", "met at a conference"}}
|
||||
headers := []string{"Email", "Job Title", "Firmenname", "Org", "Newsletter", "Notes", "Mobil"}
|
||||
sample := [][]string{{"a@x.com", "CEO", "Acme", "Acme", "yes", "met at a conference", "+49 151 2345678"}}
|
||||
mapping := ignored(len(headers))
|
||||
mapping[0].Target = models.ContactImportTargetEmail
|
||||
|
||||
@@ -98,14 +108,18 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
|
||||
probe := &fakeAsker{answers: map[string]typesafe.Answer{}}
|
||||
Infer(context.Background(), probe, mapping, headers, Shapes(len(headers), sample), []string{"Title"})
|
||||
title := optionFor(t, probe.questions["column_2"], `"Title"`)
|
||||
if _, offered := probe.questions["column_2"].Criteria.(map[string]string)[string(models.ContactImportTargetEmail)]; offered {
|
||||
t.Errorf("Email is already filled and must not be offered")
|
||||
for id, q := range probe.questions {
|
||||
crit := q.Criteria.(map[string]string)
|
||||
for _, never := range []models.ContactImportColumnTarget{
|
||||
models.ContactImportTargetEmail, models.ContactImportTargetSubscribed, models.ContactImportTargetCategories,
|
||||
} {
|
||||
if _, offered := crit[string(never)]; offered {
|
||||
t.Errorf("%s offers %s, which stays the user's call", id, never)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, offered := probe.questions["column_2"].Criteria.(map[string]string)[string(models.ContactImportTargetSubscribed)]; offered {
|
||||
t.Errorf("Subscribed is offered for a column that is not yes or no")
|
||||
}
|
||||
if _, offered := probe.questions["column_5"].Criteria.(map[string]string)[string(models.ContactImportTargetSubscribed)]; !offered {
|
||||
t.Errorf("Subscribed is not offered for a yes or no column")
|
||||
if _, offered := probe.questions["column_7"].Criteria.(map[string]string)[string(models.ContactImportTargetFirstName)]; offered {
|
||||
t.Errorf("a column of phone numbers is offered First name")
|
||||
}
|
||||
|
||||
f := &fakeAsker{answers: map[string]typesafe.Answer{
|
||||
@@ -113,10 +127,12 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
|
||||
// Two columns want Company: the more confident one gets it.
|
||||
"column_3": {Choice: string(models.ContactImportTargetCompany), Confidence: 0.95},
|
||||
"column_4": {Choice: string(models.ContactImportTargetCompany), Confidence: 0.80},
|
||||
// Under the floor: left for the user.
|
||||
"column_5": {Choice: string(models.ContactImportTargetSubscribed), Confidence: 0.55},
|
||||
// Offered to text columns, never to this yes or no one.
|
||||
"column_5": {Choice: string(models.ContactImportTargetLastName), Confidence: 0.99},
|
||||
"column_6": {Choice: optionNone, Confidence: 0.99},
|
||||
// Never asked, and an option nobody offered: both ignored.
|
||||
// Under the floor: left for the user.
|
||||
"column_7": {Choice: string(models.ContactImportTargetPhone), Confidence: 0.55},
|
||||
// Never asked, and an id nobody offered: both ignored.
|
||||
"column_1": {Choice: string(models.ContactImportTargetPhone), Confidence: 0.99},
|
||||
"column_9": {Choice: "made_up", Confidence: 0.99},
|
||||
}}
|
||||
@@ -129,6 +145,7 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
|
||||
{Index: 3, Target: models.ContactImportTargetIgnore},
|
||||
{Index: 4, Target: models.ContactImportTargetIgnore},
|
||||
{Index: 5, Target: models.ContactImportTargetIgnore},
|
||||
{Index: 6, Target: models.ContactImportTargetIgnore},
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
@@ -141,6 +158,26 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
|
||||
if mapping[1].Target != models.ContactImportTargetIgnore {
|
||||
t.Errorf("Infer changed the caller's mapping in place")
|
||||
}
|
||||
|
||||
// A different field that no other column wants is still applied.
|
||||
f.answers["column_7"] = typesafe.Answer{Choice: string(models.ContactImportTargetPhone), Confidence: 0.9}
|
||||
got, _ = Infer(context.Background(), f, mapping, headers, Shapes(len(headers), sample), []string{"Title"})
|
||||
if got[6].Target != models.ContactImportTargetPhone {
|
||||
t.Errorf("phone column: got %+v", got[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferSendsLongHeadersCapped(t *testing.T) {
|
||||
long := "What is the primary reason your company is evaluating outreach tools this quarter, in a few words?"
|
||||
f := &fakeAsker{}
|
||||
Infer(context.Background(), f, ignored(2), []string{"Email", long}, []Shape{ShapeEmail, ShapeText}, nil)
|
||||
q, ok := f.questions["column_2"]
|
||||
if !ok {
|
||||
t.Fatalf("a long header was not asked about")
|
||||
}
|
||||
if strings.Contains(q.Instructions, long) || !strings.Contains(q.Instructions, long[:headerRunes]) {
|
||||
t.Errorf("header not capped at %d runes: %s", headerRunes, q.Instructions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferFallsBackToTheSuggestion(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user