From b8e7b9b7fd0ad1e7398663b279545b7a573db511 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Tue, 27 Jan 2026 14:29:55 +0000 Subject: [PATCH] Working LLM responses and Twilio status tracking The responses aren't good, but they do exist. --- api/twilio.go | 1 + comms/text/text.go | 17 +- db/dberrors/comms.text_log.bob.go | 9 + db/dbinfo/comms.text_log.bob.go | 73 +++++- db/enums/enums.bob.go | 8 +- db/factory/bobfactory_main.bob.go | 2 + db/factory/comms.text_log.bob.go | 122 +++++++++- .../00044_comms_text_origin_user.sql | 6 + db/migrations/00045_comms_text_sid.sql | 8 + db/models/comms.text_log.bob.go | 148 ++++++++---- llm/client.go | 18 +- llm/openai.go | 162 ++++---------- platform/text.go | 210 +++++++++++------- 13 files changed, 497 insertions(+), 287 deletions(-) create mode 100644 db/migrations/00044_comms_text_origin_user.sql create mode 100644 db/migrations/00045_comms_text_sid.sql diff --git a/api/twilio.go b/api/twilio.go index 9a252e80..91ee54ba 100644 --- a/api/twilio.go +++ b/api/twilio.go @@ -18,6 +18,7 @@ func twilioStatusPost(w http.ResponseWriter, r *http.Request) { message_sid := r.PostFormValue("MessageSid") message_status := r.PostFormValue("MessageStatus") log.Info().Str("sid", message_sid).Str("status", message_status).Msg("Updated message status") + platform.UpdateMessageStatus(message_sid, message_status) fmt.Fprintf(w, "") } func twilioTextPost(w http.ResponseWriter, r *http.Request) { diff --git a/comms/text/text.go b/comms/text/text.go index 34d2aa7a..c98e33fc 100644 --- a/comms/text/text.go +++ b/comms/text/text.go @@ -18,7 +18,7 @@ func ParsePhoneNumber(input string) (*E164, error) { return phonenumbers.Parse(input, "US") } -func SendText(ctx context.Context, source string, destination string, message string) error { +func SendText(ctx context.Context, source string, destination string, message string) (string, error) { client := twilio.NewRestClient() params := &twilioApi.CreateMessageParams{} @@ -29,15 +29,14 @@ func SendText(ctx context.Context, source string, destination string, message st resp, err := client.Api.CreateMessage(params) if err != nil { - return fmt.Errorf("Failed to create message to %s: %w", destination, err) - } else { - if resp.Body != nil { - log.Info().Str("dest", destination).Str("body", *resp.Body).Msg("Text message response") - } else { - log.Info().Str("dest", destination).Msg("Text message response is nil") - } + return "", fmt.Errorf("Failed to create message to %s: %w", destination, err) } - return nil + //log.Info().Str("dest", destination).Str("sid", *resp.Body).Msg("Text message response") + if resp.Sid == nil { + log.Warn().Str("src", source).Str("dst", destination).Msg("Text message sid is nil") + return "", nil + } + return *resp.Sid, nil } func sendSMS(destination, source, message string) error { diff --git a/db/dberrors/comms.text_log.bob.go b/db/dberrors/comms.text_log.bob.go index 8bd6a9dc..541e7178 100644 --- a/db/dberrors/comms.text_log.bob.go +++ b/db/dberrors/comms.text_log.bob.go @@ -10,8 +10,17 @@ var CommsTextLogErrors = &commsTextLogErrors{ columns: []string{"id"}, s: "text_log_pkey", }, + + ErrUniqueTextLogTwilioSidKey: &UniqueConstraintError{ + schema: "comms", + table: "text_log", + columns: []string{"twilio_sid"}, + s: "text_log_twilio_sid_key", + }, } type commsTextLogErrors struct { ErrUniqueTextLogPkey *UniqueConstraintError + + ErrUniqueTextLogTwilioSidKey *UniqueConstraintError } diff --git a/db/dbinfo/comms.text_log.bob.go b/db/dbinfo/comms.text_log.bob.go index 0e4a1329..d85d75d6 100644 --- a/db/dbinfo/comms.text_log.bob.go +++ b/db/dbinfo/comms.text_log.bob.go @@ -78,6 +78,24 @@ var CommsTextLogs = Table[ Generated: false, AutoIncr: false, }, + TwilioSid: column{ + Name: "twilio_sid", + DBType: "text", + Default: "NULL", + Comment: "", + Nullable: true, + Generated: false, + AutoIncr: false, + }, + TwilioStatus: column{ + Name: "twilio_status", + DBType: "text", + Default: "", + Comment: "", + Nullable: false, + Generated: false, + AutoIncr: false, + }, }, Indexes: commsTextLogIndexes{ TextLogPkey: index{ @@ -97,6 +115,23 @@ var CommsTextLogs = Table[ Where: "", Include: []string{}, }, + TextLogTwilioSidKey: index{ + Type: "btree", + Name: "text_log_twilio_sid_key", + Columns: []indexColumn{ + { + Name: "twilio_sid", + Desc: null.FromCond(false, true), + IsExpression: false, + }, + }, + Unique: true, + Comment: "", + NullsFirst: []bool{false}, + NullsDistinct: false, + Where: "", + Include: []string{}, + }, }, PrimaryKey: &constraint{ Name: "text_log_pkey", @@ -123,33 +158,43 @@ var CommsTextLogs = Table[ ForeignColumns: []string{"e164"}, }, }, + Uniques: commsTextLogUniques{ + TextLogTwilioSidKey: constraint{ + Name: "text_log_twilio_sid_key", + Columns: []string{"twilio_sid"}, + Comment: "", + }, + }, Comment: "Used to track text messages that were sent.", } type commsTextLogColumns struct { - Content column - Created column - Destination column - ID column - IsWelcome column - Origin column - Source column + Content column + Created column + Destination column + ID column + IsWelcome column + Origin column + Source column + TwilioSid column + TwilioStatus column } func (c commsTextLogColumns) AsSlice() []column { return []column{ - c.Content, c.Created, c.Destination, c.ID, c.IsWelcome, c.Origin, c.Source, + c.Content, c.Created, c.Destination, c.ID, c.IsWelcome, c.Origin, c.Source, c.TwilioSid, c.TwilioStatus, } } type commsTextLogIndexes struct { - TextLogPkey index + TextLogPkey index + TextLogTwilioSidKey index } func (i commsTextLogIndexes) AsSlice() []index { return []index{ - i.TextLogPkey, + i.TextLogPkey, i.TextLogTwilioSidKey, } } @@ -164,10 +209,14 @@ func (f commsTextLogForeignKeys) AsSlice() []foreignKey { } } -type commsTextLogUniques struct{} +type commsTextLogUniques struct { + TextLogTwilioSidKey constraint +} func (u commsTextLogUniques) AsSlice() []constraint { - return []constraint{} + return []constraint{ + u.TextLogTwilioSidKey, + } } type commsTextLogChecks struct{} diff --git a/db/enums/enums.bob.go b/db/enums/enums.bob.go index 81f030ec..cadb022f 100644 --- a/db/enums/enums.bob.go +++ b/db/enums/enums.bob.go @@ -347,6 +347,8 @@ const ( CommsTextoriginDistrict CommsTextorigin = "district" CommsTextoriginLLM CommsTextorigin = "llm" CommsTextoriginWebsiteAction CommsTextorigin = "website-action" + CommsTextoriginCustomer CommsTextorigin = "customer" + CommsTextoriginReiteration CommsTextorigin = "reiteration" ) func AllCommsTextorigin() []CommsTextorigin { @@ -354,6 +356,8 @@ func AllCommsTextorigin() []CommsTextorigin { CommsTextoriginDistrict, CommsTextoriginLLM, CommsTextoriginWebsiteAction, + CommsTextoriginCustomer, + CommsTextoriginReiteration, } } @@ -367,7 +371,9 @@ func (e CommsTextorigin) Valid() bool { switch e { case CommsTextoriginDistrict, CommsTextoriginLLM, - CommsTextoriginWebsiteAction: + CommsTextoriginWebsiteAction, + CommsTextoriginCustomer, + CommsTextoriginReiteration: return true default: return false diff --git a/db/factory/bobfactory_main.bob.go b/db/factory/bobfactory_main.bob.go index 45b2b33f..f8f5ca98 100644 --- a/db/factory/bobfactory_main.bob.go +++ b/db/factory/bobfactory_main.bob.go @@ -368,6 +368,8 @@ func (f *Factory) FromExistingCommsTextLog(m *models.CommsTextLog) *CommsTextLog o.IsWelcome = func() bool { return m.IsWelcome } o.Origin = func() enums.CommsTextorigin { return m.Origin } o.Source = func() string { return m.Source } + o.TwilioSid = func() null.Val[string] { return m.TwilioSid } + o.TwilioStatus = func() string { return m.TwilioStatus } ctx := context.Background() if m.R.DestinationPhone != nil { diff --git a/db/factory/comms.text_log.bob.go b/db/factory/comms.text_log.bob.go index d2e88519..5edb4b92 100644 --- a/db/factory/comms.text_log.bob.go +++ b/db/factory/comms.text_log.bob.go @@ -10,7 +10,9 @@ import ( enums "github.com/Gleipnir-Technology/nidus-sync/db/enums" models "github.com/Gleipnir-Technology/nidus-sync/db/models" + "github.com/aarondl/opt/null" "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" "github.com/jaswdr/faker/v2" "github.com/stephenafamo/bob" ) @@ -36,13 +38,15 @@ func (mods CommsTextLogModSlice) Apply(ctx context.Context, n *CommsTextLogTempl // CommsTextLogTemplate is an object representing the database table. // all columns are optional and should be set by mods type CommsTextLogTemplate struct { - Content func() string - Created func() time.Time - Destination func() string - ID func() int32 - IsWelcome func() bool - Origin func() enums.CommsTextorigin - Source func() string + Content func() string + Created func() time.Time + Destination func() string + ID func() int32 + IsWelcome func() bool + Origin func() enums.CommsTextorigin + Source func() string + TwilioSid func() null.Val[string] + TwilioStatus func() string r commsTextLogR f *Factory @@ -120,6 +124,14 @@ func (o CommsTextLogTemplate) BuildSetter() *models.CommsTextLogSetter { val := o.Source() m.Source = omit.From(val) } + if o.TwilioSid != nil { + val := o.TwilioSid() + m.TwilioSid = omitnull.FromNull(val) + } + if o.TwilioStatus != nil { + val := o.TwilioStatus() + m.TwilioStatus = omit.From(val) + } return m } @@ -163,6 +175,12 @@ func (o CommsTextLogTemplate) Build() *models.CommsTextLog { if o.Source != nil { m.Source = o.Source() } + if o.TwilioSid != nil { + m.TwilioSid = o.TwilioSid() + } + if o.TwilioStatus != nil { + m.TwilioStatus = o.TwilioStatus() + } o.setModelRels(m) @@ -207,6 +225,10 @@ func ensureCreatableCommsTextLog(m *models.CommsTextLogSetter) { val := random_string(nil) m.Source = omit.From(val) } + if !(m.TwilioStatus.IsValue()) { + val := random_string(nil) + m.TwilioStatus = omit.From(val) + } } // insertOptRels creates and inserts any optional the relationships on *models.CommsTextLog @@ -351,6 +373,8 @@ func (m commsTextLogMods) RandomizeAllColumns(f *faker.Faker) CommsTextLogMod { CommsTextLogMods.RandomIsWelcome(f), CommsTextLogMods.RandomOrigin(f), CommsTextLogMods.RandomSource(f), + CommsTextLogMods.RandomTwilioSid(f), + CommsTextLogMods.RandomTwilioStatus(f), } } @@ -571,6 +595,90 @@ func (m commsTextLogMods) RandomSource(f *faker.Faker) CommsTextLogMod { }) } +// Set the model columns to this value +func (m commsTextLogMods) TwilioSid(val null.Val[string]) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.TwilioSid = func() null.Val[string] { return val } + }) +} + +// Set the Column from the function +func (m commsTextLogMods) TwilioSidFunc(f func() null.Val[string]) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.TwilioSid = f + }) +} + +// Clear any values for the column +func (m commsTextLogMods) UnsetTwilioSid() CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.TwilioSid = nil + }) +} + +// Generates a random value for the column using the given faker +// if faker is nil, a default faker is used +// The generated value is sometimes null +func (m commsTextLogMods) RandomTwilioSid(f *faker.Faker) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.TwilioSid = func() null.Val[string] { + if f == nil { + f = &defaultFaker + } + + val := random_string(f) + return null.From(val) + } + }) +} + +// Generates a random value for the column using the given faker +// if faker is nil, a default faker is used +// The generated value is never null +func (m commsTextLogMods) RandomTwilioSidNotNull(f *faker.Faker) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.TwilioSid = func() null.Val[string] { + if f == nil { + f = &defaultFaker + } + + val := random_string(f) + return null.From(val) + } + }) +} + +// Set the model columns to this value +func (m commsTextLogMods) TwilioStatus(val string) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.TwilioStatus = func() string { return val } + }) +} + +// Set the Column from the function +func (m commsTextLogMods) TwilioStatusFunc(f func() string) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.TwilioStatus = f + }) +} + +// Clear any values for the column +func (m commsTextLogMods) UnsetTwilioStatus() CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.TwilioStatus = nil + }) +} + +// Generates a random value for the column using the given faker +// if faker is nil, a default faker is used +func (m commsTextLogMods) RandomTwilioStatus(f *faker.Faker) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.TwilioStatus = func() string { + return random_string(f) + } + }) +} + func (m commsTextLogMods) WithParentsCascading() CommsTextLogMod { return CommsTextLogModFunc(func(ctx context.Context, o *CommsTextLogTemplate) { if isDone, _ := commsTextLogWithParentsCascadingCtx.Value(ctx); isDone { diff --git a/db/migrations/00044_comms_text_origin_user.sql b/db/migrations/00044_comms_text_origin_user.sql new file mode 100644 index 00000000..f8aa6829 --- /dev/null +++ b/db/migrations/00044_comms_text_origin_user.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TYPE comms.TextOrigin ADD VALUE 'customer'; +ALTER TYPE comms.TextOrigin ADD VALUE 'reiteration'; +-- +goose Down +ALTER TYPE comms.TextOrigin DROP VALUE 'reiteration'; +ALTER TYPE comms.TextOrigin DROP VALUE 'customer'; diff --git a/db/migrations/00045_comms_text_sid.sql b/db/migrations/00045_comms_text_sid.sql new file mode 100644 index 00000000..71b0e09e --- /dev/null +++ b/db/migrations/00045_comms_text_sid.sql @@ -0,0 +1,8 @@ +-- +goose Up +ALTER TABLE comms.text_log ADD COLUMN twilio_sid TEXT UNIQUE; +ALTER TABLE comms.text_log ADD COLUMN twilio_status TEXT; +UPDATE comms.text_log SET twilio_status = ''; +ALTER TABLE comms.text_log ALTER COLUMN twilio_status SET NOT NULL; +-- +goose Down +ALTER TABLE comms.text_log DROP COLUMN twilio_status; +ALTER TABLE comms.text_log DROP COLUMN twilio_sid; diff --git a/db/models/comms.text_log.bob.go b/db/models/comms.text_log.bob.go index 445ac37f..45e671d0 100644 --- a/db/models/comms.text_log.bob.go +++ b/db/models/comms.text_log.bob.go @@ -10,7 +10,9 @@ import ( "time" enums "github.com/Gleipnir-Technology/nidus-sync/db/enums" + "github.com/aarondl/opt/null" "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" "github.com/stephenafamo/bob" "github.com/stephenafamo/bob/dialect/psql" "github.com/stephenafamo/bob/dialect/psql/dialect" @@ -25,13 +27,15 @@ import ( // CommsTextLog is an object representing the database table. type CommsTextLog struct { - Content string `db:"content" ` - Created time.Time `db:"created" ` - Destination string `db:"destination" ` - ID int32 `db:"id,pk" ` - IsWelcome bool `db:"is_welcome" ` - Origin enums.CommsTextorigin `db:"origin" ` - Source string `db:"source" ` + Content string `db:"content" ` + Created time.Time `db:"created" ` + Destination string `db:"destination" ` + ID int32 `db:"id,pk" ` + IsWelcome bool `db:"is_welcome" ` + Origin enums.CommsTextorigin `db:"origin" ` + Source string `db:"source" ` + TwilioSid null.Val[string] `db:"twilio_sid" ` + TwilioStatus string `db:"twilio_status" ` R commsTextLogR `db:"-" ` } @@ -55,29 +59,33 @@ type commsTextLogR struct { func buildCommsTextLogColumns(alias string) commsTextLogColumns { return commsTextLogColumns{ ColumnsExpr: expr.NewColumnsExpr( - "content", "created", "destination", "id", "is_welcome", "origin", "source", + "content", "created", "destination", "id", "is_welcome", "origin", "source", "twilio_sid", "twilio_status", ).WithParent("comms.text_log"), - tableAlias: alias, - Content: psql.Quote(alias, "content"), - Created: psql.Quote(alias, "created"), - Destination: psql.Quote(alias, "destination"), - ID: psql.Quote(alias, "id"), - IsWelcome: psql.Quote(alias, "is_welcome"), - Origin: psql.Quote(alias, "origin"), - Source: psql.Quote(alias, "source"), + tableAlias: alias, + Content: psql.Quote(alias, "content"), + Created: psql.Quote(alias, "created"), + Destination: psql.Quote(alias, "destination"), + ID: psql.Quote(alias, "id"), + IsWelcome: psql.Quote(alias, "is_welcome"), + Origin: psql.Quote(alias, "origin"), + Source: psql.Quote(alias, "source"), + TwilioSid: psql.Quote(alias, "twilio_sid"), + TwilioStatus: psql.Quote(alias, "twilio_status"), } } type commsTextLogColumns struct { expr.ColumnsExpr - tableAlias string - Content psql.Expression - Created psql.Expression - Destination psql.Expression - ID psql.Expression - IsWelcome psql.Expression - Origin psql.Expression - Source psql.Expression + tableAlias string + Content psql.Expression + Created psql.Expression + Destination psql.Expression + ID psql.Expression + IsWelcome psql.Expression + Origin psql.Expression + Source psql.Expression + TwilioSid psql.Expression + TwilioStatus psql.Expression } func (c commsTextLogColumns) Alias() string { @@ -92,17 +100,19 @@ func (commsTextLogColumns) AliasedAs(alias string) commsTextLogColumns { // All values are optional, and do not have to be set // Generated columns are not included type CommsTextLogSetter struct { - Content omit.Val[string] `db:"content" ` - Created omit.Val[time.Time] `db:"created" ` - Destination omit.Val[string] `db:"destination" ` - ID omit.Val[int32] `db:"id,pk" ` - IsWelcome omit.Val[bool] `db:"is_welcome" ` - Origin omit.Val[enums.CommsTextorigin] `db:"origin" ` - Source omit.Val[string] `db:"source" ` + Content omit.Val[string] `db:"content" ` + Created omit.Val[time.Time] `db:"created" ` + Destination omit.Val[string] `db:"destination" ` + ID omit.Val[int32] `db:"id,pk" ` + IsWelcome omit.Val[bool] `db:"is_welcome" ` + Origin omit.Val[enums.CommsTextorigin] `db:"origin" ` + Source omit.Val[string] `db:"source" ` + TwilioSid omitnull.Val[string] `db:"twilio_sid" ` + TwilioStatus omit.Val[string] `db:"twilio_status" ` } func (s CommsTextLogSetter) SetColumns() []string { - vals := make([]string, 0, 7) + vals := make([]string, 0, 9) if s.Content.IsValue() { vals = append(vals, "content") } @@ -124,6 +134,12 @@ func (s CommsTextLogSetter) SetColumns() []string { if s.Source.IsValue() { vals = append(vals, "source") } + if !s.TwilioSid.IsUnset() { + vals = append(vals, "twilio_sid") + } + if s.TwilioStatus.IsValue() { + vals = append(vals, "twilio_status") + } return vals } @@ -149,6 +165,12 @@ func (s CommsTextLogSetter) Overwrite(t *CommsTextLog) { if s.Source.IsValue() { t.Source = s.Source.MustGet() } + if !s.TwilioSid.IsUnset() { + t.TwilioSid = s.TwilioSid.MustGetNull() + } + if s.TwilioStatus.IsValue() { + t.TwilioStatus = s.TwilioStatus.MustGet() + } } func (s *CommsTextLogSetter) Apply(q *dialect.InsertQuery) { @@ -157,7 +179,7 @@ func (s *CommsTextLogSetter) Apply(q *dialect.InsertQuery) { }) q.AppendValues(bob.ExpressionFunc(func(ctx context.Context, w io.StringWriter, d bob.Dialect, start int) ([]any, error) { - vals := make([]bob.Expression, 7) + vals := make([]bob.Expression, 9) if s.Content.IsValue() { vals[0] = psql.Arg(s.Content.MustGet()) } else { @@ -200,6 +222,18 @@ func (s *CommsTextLogSetter) Apply(q *dialect.InsertQuery) { vals[6] = psql.Raw("DEFAULT") } + if !s.TwilioSid.IsUnset() { + vals[7] = psql.Arg(s.TwilioSid.MustGetNull()) + } else { + vals[7] = psql.Raw("DEFAULT") + } + + if s.TwilioStatus.IsValue() { + vals[8] = psql.Arg(s.TwilioStatus.MustGet()) + } else { + vals[8] = psql.Raw("DEFAULT") + } + return bob.ExpressSlice(ctx, w, d, start, vals, "", ", ", "") })) } @@ -209,7 +243,7 @@ func (s CommsTextLogSetter) UpdateMod() bob.Mod[*dialect.UpdateQuery] { } func (s CommsTextLogSetter) Expressions(prefix ...string) []bob.Expression { - exprs := make([]bob.Expression, 0, 7) + exprs := make([]bob.Expression, 0, 9) if s.Content.IsValue() { exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ @@ -260,6 +294,20 @@ func (s CommsTextLogSetter) Expressions(prefix ...string) []bob.Expression { }}) } + if !s.TwilioSid.IsUnset() { + exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ + psql.Quote(append(prefix, "twilio_sid")...), + psql.Arg(s.TwilioSid), + }}) + } + + if s.TwilioStatus.IsValue() { + exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ + psql.Quote(append(prefix, "twilio_status")...), + psql.Arg(s.TwilioStatus), + }}) + } + return exprs } @@ -631,13 +679,15 @@ func (commsTextLog0 *CommsTextLog) AttachSourcePhone(ctx context.Context, exec b } type commsTextLogWhere[Q psql.Filterable] struct { - Content psql.WhereMod[Q, string] - Created psql.WhereMod[Q, time.Time] - Destination psql.WhereMod[Q, string] - ID psql.WhereMod[Q, int32] - IsWelcome psql.WhereMod[Q, bool] - Origin psql.WhereMod[Q, enums.CommsTextorigin] - Source psql.WhereMod[Q, string] + Content psql.WhereMod[Q, string] + Created psql.WhereMod[Q, time.Time] + Destination psql.WhereMod[Q, string] + ID psql.WhereMod[Q, int32] + IsWelcome psql.WhereMod[Q, bool] + Origin psql.WhereMod[Q, enums.CommsTextorigin] + Source psql.WhereMod[Q, string] + TwilioSid psql.WhereNullMod[Q, string] + TwilioStatus psql.WhereMod[Q, string] } func (commsTextLogWhere[Q]) AliasedAs(alias string) commsTextLogWhere[Q] { @@ -646,13 +696,15 @@ func (commsTextLogWhere[Q]) AliasedAs(alias string) commsTextLogWhere[Q] { func buildCommsTextLogWhere[Q psql.Filterable](cols commsTextLogColumns) commsTextLogWhere[Q] { return commsTextLogWhere[Q]{ - Content: psql.Where[Q, string](cols.Content), - Created: psql.Where[Q, time.Time](cols.Created), - Destination: psql.Where[Q, string](cols.Destination), - ID: psql.Where[Q, int32](cols.ID), - IsWelcome: psql.Where[Q, bool](cols.IsWelcome), - Origin: psql.Where[Q, enums.CommsTextorigin](cols.Origin), - Source: psql.Where[Q, string](cols.Source), + Content: psql.Where[Q, string](cols.Content), + Created: psql.Where[Q, time.Time](cols.Created), + Destination: psql.Where[Q, string](cols.Destination), + ID: psql.Where[Q, int32](cols.ID), + IsWelcome: psql.Where[Q, bool](cols.IsWelcome), + Origin: psql.Where[Q, enums.CommsTextorigin](cols.Origin), + Source: psql.Where[Q, string](cols.Source), + TwilioSid: psql.WhereNull[Q, string](cols.TwilioSid), + TwilioStatus: psql.Where[Q, string](cols.TwilioStatus), } } diff --git a/llm/client.go b/llm/client.go index 45ff2a24..af53d5f2 100644 --- a/llm/client.go +++ b/llm/client.go @@ -1,7 +1,9 @@ package llm import ( - "github.com/rs/zerolog/log" + "context" + "fmt" + //"github.com/rs/zerolog/log" ) type Message struct { @@ -9,14 +11,10 @@ type Message struct { IsFromCustomer bool } -func GenerateNextMessage(history []Message, current Message) (Message, error) { - // In general our history - for i, msg := range history { - log.Info().Int("i", i).Bool("is_customer", msg.IsFromCustomer).Msg("History") +func GenerateNextMessage(ctx context.Context, history []Message, customer_phone string) (Message, error) { + next, err := client.continueConversation(ctx, history, customer_phone) + if err != nil { + return Message{}, fmt.Errorf("Failed to generate next message: %w", err) } - - return Message{ - Content: "hey there. :)", - IsFromCustomer: false, - }, nil + return next, nil } diff --git a/llm/openai.go b/llm/openai.go index d936bb0d..b971d1a3 100644 --- a/llm/openai.go +++ b/llm/openai.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" "github.com/maruel/genai" "github.com/maruel/genai/adapters" @@ -12,21 +11,6 @@ import ( "github.com/rs/zerolog/log" ) -type openAIClient struct { - client *openaichat.Client - conversations map[string][]genai.Message - log *Logger -} - -var client *openAIClient - -type AIRequest struct { - Displayname string - Message string - Sender string - Timestamp time.Time -} - func CreateOpenAIClient(ctx context.Context) error { logger := createLogger() @@ -45,131 +29,73 @@ func CreateOpenAIClient(ctx context.Context) error { return nil } -func (c *openAIClient) continueConversation(ctx context.Context, req AIRequest) error { - msgs, ok := c.conversations["roomid"] - if !ok { - msgs = genai.Messages{ - c.startConversation(ctx, req), - } - } else { - msgs = append(msgs, genai.NewTextMessage(fmt.Sprintf("(%s) user: %s\nbot: ", req.Timestamp.String(), req.Message))) - } +type openAIClient struct { + client *openaichat.Client + conversations map[string][]genai.Message + log *Logger +} - c.log.Debug().Msg("Generating response...") +type QueryReportStatusInput struct { + ReportID string `json:"report_id"` +} + +var client *openAIClient + +func (c *openAIClient) continueConversation(ctx context.Context, history []Message, customer_phone string) (Message, error) { opts := genai.OptionsTools{ Tools: []genai.ToolDef{ { - Name: "followup_timer", - Description: "This should be used to indicate that the bot should follow up with the user in the future to check on task progress.", - Callback: func(ctx2 context.Context, input *FollowupTimerInput) (string, error) { - return c.followupSchedule(ctx2, req, input) - }, - }, { - Name: "switch_task", - Description: "Any time the user indicates they change tasks this must be called to update the record of what tasks are being done.", - Callback: func(ctx2 context.Context, input *SwitchTaskInput) (string, error) { - return c.switchTask(ctx2, req, input) + Name: "query_report_status", + Description: "This is used to answer any questions about the current state of the mosquito nuisance report.", + Callback: func(ctx2 context.Context, input *QueryReportStatusInput) (string, error) { + return c.queryReportStatus(ctx2, customer_phone) }, }, }, } - res, _, err := adapters.GenSyncWithToolCallLoop(ctx, c.client, msgs, &opts) + msg := c.convertHistory(history) + res, _, err := adapters.GenSyncWithToolCallLoop(ctx, c.client, genai.Messages{msg}, &opts) if err != nil { - return fmt.Errorf("Failed to continue conversation: %v", err) + return Message{}, fmt.Errorf("Failed to continue conversation: %v", err) } for _, m := range res { - msgs = append(msgs, m) // Empty responses are tool call related. if m.String() == "" { + log.Debug().Msg("Tool called") } else { - //c.log.Info().Str("room", req.RoomID.String()).Msg(m.String()) var toSay string = m.String() - toSay = strings.Replace(toSay, "bot: ", "", 1) - log.Info().Str("to say", toSay).Msg("Responding") - /*c.aiResponseChannel <- AIResponse{ - Message: toSay, - RoomID: req.RoomID, - }*/ + toSay = strings.Replace(toSay, "report-mosquitoes-online: ", "", 1) + return Message{ + Content: toSay, + IsFromCustomer: false, + }, nil } } - //c.conversations[req.RoomID.String()] = msgs - return nil + return Message{}, nil } -type FollowupTimerInput struct { - DelayInSeconds int64 `json:"delay_in_seconds"` -} - -func (c *openAIClient) followupFire(ctx context.Context, req AIRequest, duration time.Duration) { - if err := ctx.Err(); err != nil { - //c.log.Info().Str("room", req.RoomID.String()).Msg("Context canceled") - return +func (c *openAIClient) convertHistory(history []Message) genai.Message { + var sb strings.Builder + sb.WriteString( + `This is a text chat conversation between a customer that's a member of the public and a mosquito abatement district. + The customer has reported a mosquito nuisance or mosquito breeding through the website report.mosquitoes.online. + Messages from the customer are prefixed with 'customer:' and reponses from the service agent servicing the request are prefixed with 'agent:'. + The agent wants to provide clear, confident, and succint information about the state of the customer's request. The agent also provides general information about how members of the public can help with controlling mosquitoes. For complex or highly specific requests, the agent will need to defer to the mosquito abatement district. This will take some time because contacting the district may take a few hours to get a response. When the agent needs to contact the district, the agent should tell the customer they are reaching out to the district and to expect a delay. + Transcript starts:`, + ) + for _, h := range history { + if h.IsFromCustomer { + sb.WriteString(fmt.Sprintf("\n\ncustomer (%s): %s\n", h.Content)) + } else { + sb.WriteString(fmt.Sprintf("\n\nagent (%s): %s\n", h.Content)) + } } - msgs, ok := c.conversations["roomid"] - if !ok { - //c.log.Warn().Str("room", req.RoomID.String()).Str("elapsed", duration.String()).Msg("No messages for room") - return - } - msgs = append(msgs, genai.NewTextMessage(fmt.Sprintf("<%s passed>", duration.String()))) - res, err := c.client.GenSync(ctx, msgs) - if err != nil { - //c.log.Error().Str("room", req.RoomID.String()).Err(err).Msg("Failed to continue after timer") - return - } - msgs = append(msgs, res.Message) - var toSay string = res.String() - toSay = strings.Replace(toSay, "bot: ", "", 1) - log.Info().Str("to say", toSay).Msg("To say") - /*c.aiResponseChannel <- AIResponse{ - Message: toSay, - RoomID: req.RoomID, - } - c.conversations[req.RoomID.String()] = msgs - */ + return genai.NewTextMessage(sb.String()) } -func (c *openAIClient) followupSchedule(ctx context.Context, req AIRequest, input *FollowupTimerInput) (string, error) { - //c.log.Info().Str("room", req.RoomID.String()).Int64("delay", input.DelayInSeconds).Msg("Followup timer scheduled.") - duration, err := time.ParseDuration(fmt.Sprintf("%ds", input.DelayInSeconds)) - if err != nil { - return "", fmt.Errorf("Failed to parse %d as a valid duration: %v", input.DelayInSeconds, err) - } - /*c.aiResponseChannel <- AIResponse{ - Message: fmt.Sprintf("⌛ followup scheduled '%s'", duration.String()), - RoomID: req.RoomID, - }*/ - time.AfterFunc(duration, func() { - c.followupFire(ctx, req, duration) - }) - return fmt.Sprintf("Followup timer set for %s in the future", duration.String()), nil -} - -type SwitchTaskInput struct { - TaskName string `json:"task_name"` -} - -func (c *openAIClient) switchTask(ctx context.Context, req AIRequest, input *SwitchTaskInput) (string, error) { - //c.log.Info().Str("room", req.RoomID.String()).Str("task", input.TaskName).Msg("Task Switched") - /*c.aiResponseChannel <- AIResponse{ - Message: fmt.Sprintf("📋 notes task '%s'", input.TaskName), - RoomID: req.RoomID, - }*/ - - return fmt.Sprintf("Recorded a switch to task %s at %s", input.TaskName, time.Now().String()), nil -} - -func (c *openAIClient) startConversation(ctx context.Context, req AIRequest) genai.Message { - return genai.NewTextMessage(fmt.Sprintf( - `This is a text chat conversation between an employee and a chatbot helping to manage timecards. - The user's name is '%[1]s'. - Messages from the user will start with '(timestamp) %[1]s:'. - Messages from the bot will start with 'bot:'. - Sometimes the user won't say anything for a long time and the chatbot needs to follow-up with them. - When time passes, there will be a prompt like '<200s passed>'. - The bot should then prompt the user to provide a bit of information about what they've been working on during that time. - The bot should be interested to know what the user's goals are at a high level and should pay attention to any difficulties or frustrations the user experiences.\n\n - (%[2]s) user: %[3]s\nbot:`, req.Displayname, req.Timestamp.String(), req.Message)) +func (c *openAIClient) queryReportStatus(ctx context.Context, customer_phone string) (string, error) { + return "Report is scheduled for work in 3 days at 2:00pm by the district", nil } diff --git a/platform/text.go b/platform/text.go index f77a6e77..cebc16e4 100644 --- a/platform/text.go +++ b/platform/text.go @@ -19,12 +19,97 @@ import ( "github.com/rs/zerolog/log" ) +func HandleTextMessage(from string, to string, body string) { + ctx := context.Background() + type_, src := splitPhoneSource(from) + dst, err := getDst(ctx, to) + if err != nil { + log.Error().Err(err).Str("to", to).Msg("Failed to get dst") + return + } + + _, err = insertTextLog(ctx, body, dst, src, enums.CommsTextoriginCustomer, false) + if err != nil { + log.Error().Err(err).Str("dst", dst).Msg("Failed to add text message log") + return + } + subscribed, err := isSubscribed(ctx, src) + if err != nil { + log.Error().Err(err).Msg("Failed to handle message") + return + } + // We don't know if they're subscribed or not. + if subscribed == nil { + body_l := strings.TrimSpace(strings.ToLower(body)) + switch body_l { + case "stop": + setSubscribed(ctx, src, false) + case "yes": + setSubscribed(ctx, src, true) + handleWaitingTextJobs(ctx, src) + default: + content := "I have to start with either 'YES' or 'STOP' first, Which do you want?" + /*err := insertTextLog(ctx, body, src, dst, enums.CommsTextoriginReiteration, false) + if err != nil { + log.Error().Err(err).Msg("Failed to add reiteration to the text log") + return + }*/ + err = sendText(ctx, src, dst, content, enums.CommsTextoriginReiteration, false) + if err != nil { + log.Error().Err(err).Msg("Failed to resend initial prompt.") + } + } + return + } + previous_messages, err := loadPreviousMessages(ctx, dst, src) + if err != nil { + log.Error().Err(err).Str("dst", dst).Str("src", from).Msg("Failed to get previous messages") + return + } + log.Info().Int("len", len(previous_messages)).Msg("passing") + next_message, err := llm.GenerateNextMessage(ctx, previous_messages, src) + if err != nil { + log.Error().Err(err).Str("dst", dst).Str("src", from).Msg("Failed to generate next message") + return + } + /* + err = insertTextLog(ctx, next_message.Content, src, dst, enums.CommsTextoriginLLM, false) + if err != nil { + log.Error().Err(err).Str("dst", dst).Msg("Failed to insert new text message to the text log") + return + } + */ + err = sendText(ctx, dst, src, next_message.Content, enums.CommsTextoriginLLM, false) + if err != nil { + log.Error().Err(err).Str("src", src).Str("dst", dst).Str("content", next_message.Content).Msg("Failed to send response text") + return + } + log.Info().Str("from", from).Str("from-type", type_).Str("to", to).Str("src", src).Str("dst", dst).Str("body", body).Str("reply", next_message.Content).Msg("Handled text message") +} + func TextStoreSources() error { ctx := context.TODO() src := phonenumbers.Format(&config.PhoneNumberReport, phonenumbers.E164) return ensureInDB(ctx, src) } +func UpdateMessageStatus(twilio_sid string, status string) { + ctx := context.TODO() + l, err := models.CommsTextLogs.Query( + models.SelectWhere.CommsTextLogs.TwilioSid.EQ(twilio_sid), + ).One(ctx, db.PGInstance.BobDB) + if err != nil { + log.Error().Err(err).Str("twilio_sid", twilio_sid).Str("status", status).Msg("Failed to update message status query failed") + return + } + err = l.Update(ctx, db.PGInstance.BobDB, &models.CommsTextLogSetter{ + TwilioStatus: omit.From(status), + }) + if err != nil { + log.Error().Err(err).Str("twilio_sid", twilio_sid).Str("status", status).Msg("Failed to update message status update failed") + return + } +} func delayMessage(ctx context.Context, source string, destination string, content string, type_ enums.CommsTextjobtype) error { job, err := models.CommsTextJobs.Insert(&models.CommsTextJobSetter{ Content: omit.From(content), @@ -81,20 +166,6 @@ func ensureInDB(ctx context.Context, destination string) (err error) { return nil } -func insertTextLog(ctx context.Context, content string, destination string, source string, origin enums.CommsTextorigin, is_welcome bool) (err error) { - _, err = models.CommsTextLogs.Insert(&models.CommsTextLogSetter{ - //ID: - Content: omit.From(content), - Created: omit.From(time.Now()), - Destination: omit.From(destination), - IsWelcome: omit.From(is_welcome), - Origin: omit.From(origin), - Source: omit.From(source), - }).One(ctx, db.PGInstance.BobDB) - - return err -} - // Translate from Twilio's representation of a RCS message sender to our concept of a phone number // From: rcs:dev_report_mosquitoes_online_dosrvwxm_agent // To: +16235525879 @@ -113,6 +184,39 @@ func getDst(ctx context.Context, to string) (string, error) { return "", fmt.Errorf("Cannot match phone number to '%s'", to) } +func handleWaitingTextJobs(ctx context.Context, src string) { + log.Info().Str("src", src).Msg("Pretend handle waiting jobs") + +} + +func insertTextLog(ctx context.Context, content string, destination string, source string, origin enums.CommsTextorigin, is_welcome bool) (log *models.CommsTextLog, err error) { + log, err = models.CommsTextLogs.Insert(&models.CommsTextLogSetter{ + //ID: + Content: omit.From(content), + Created: omit.From(time.Now()), + Destination: omit.From(destination), + IsWelcome: omit.From(is_welcome), + Origin: omit.From(origin), + Source: omit.From(source), + TwilioSid: omitnull.FromPtr[string](nil), + TwilioStatus: omit.From(""), + }).One(ctx, db.PGInstance.BobDB) + + return log, err +} + +func isSubscribed(ctx context.Context, src string) (*bool, error) { + phone, err := models.FindCommsPhone(ctx, db.PGInstance.BobDB, src) + if err != nil { + return nil, fmt.Errorf("Failed to determine if '%s' is subscribed: %w", src, err) + } + if phone.IsSubscribed.IsNull() { + return nil, nil + } + result := phone.IsSubscribed.MustGet() + return &result, nil +} + func loadPreviousMessages(ctx context.Context, dst, src string) ([]llm.Message, error) { messages, err := sql.TextsBySenders(dst, src).All(ctx, db.PGInstance.BobDB) results := make([]llm.Message, 0) @@ -135,11 +239,19 @@ func sendText(ctx context.Context, source string, destination string, message st if err != nil { return fmt.Errorf("Failed to ensure text message destination is in the DB: %w", err) } - err = insertTextLog(ctx, message, destination, source, origin, is_welcome) + log, err := insertTextLog(ctx, message, destination, source, origin, is_welcome) if err != nil { return fmt.Errorf("Failed to insert text message in the DB: %w", err) } - err = text.SendText(ctx, source, destination, message) + sid, err := text.SendText(ctx, source, destination, message) + if err != nil { + return fmt.Errorf("Failed to send text message: %w", err) + } + err = log.Update(ctx, db.PGInstance.BobDB, &models.CommsTextLogSetter{ + TwilioSid: omitnull.From(sid), + TwilioStatus: omit.From("created"), + }) + return nil } @@ -159,18 +271,6 @@ func splitPhoneSource(s string) (string, string) { } -func isSubscribed(ctx context.Context, src string) (*bool, error) { - phone, err := models.FindCommsPhone(ctx, db.PGInstance.BobDB, src) - if err != nil { - return nil, fmt.Errorf("Failed to determine if '%s' is subscribed: %w", src, err) - } - if phone.IsSubscribed.IsNull() { - return nil, nil - } - result := phone.IsSubscribed.MustGet() - return &result, nil -} - func setSubscribed(ctx context.Context, src string, is_subscribed bool) error { phone, err := models.FindCommsPhone(ctx, db.PGInstance.BobDB, src) if err != nil { @@ -182,57 +282,3 @@ func setSubscribed(ctx context.Context, src string, is_subscribed bool) error { log.Info().Str("src", src).Bool("is_subscribed", is_subscribed).Msg("Set number subscribed") return nil } - -func handleWaitingTextJobs(ctx context.Context, src string) { - log.Info().Str("src", src).Msg("Pretend handle waiting jobs") - -} -func HandleTextMessage(from string, to string, body string) { - ctx := context.Background() - type_, src := splitPhoneSource(from) - dst, err := getDst(ctx, to) - if err != nil { - log.Error().Err(err).Str("to", to).Msg("Failed to get dst") - return - } - subscribed, err := isSubscribed(ctx, src) - if err != nil { - log.Error().Err(err).Msg("Failed to handle message") - return - } - // We don't know if they're subscribed or not. - if subscribed == nil { - body_l := strings.TrimSpace(strings.ToLower(body)) - switch body_l { - case "stop": - setSubscribed(ctx, src, false) - case "yes": - setSubscribed(ctx, src, true) - handleWaitingTextJobs(ctx, src) - default: - content := "I have to start with either 'YES' or 'STOP' first, Which do you want?" - err := text.SendText(ctx, src, dst, content) - if err != nil { - log.Error().Err(err).Msg("Failed to resend initial prompt.") - } - } - return - } - previous_messages, err := loadPreviousMessages(ctx, dst, src) - if err != nil { - log.Error().Err(err).Str("dst", dst).Str("src", from).Msg("Failed to get previous messages") - return - } - current := llm.Message{ - Content: body, - IsFromCustomer: true, - } - log.Info().Int("len", len(previous_messages)).Msg("passing") - next_message, err := llm.GenerateNextMessage(previous_messages, current) - if err != nil { - log.Error().Err(err).Str("dst", dst).Str("src", from).Msg("Failed to generate next message") - return - } - text.SendTextFromLLM(next_message.Content) - log.Info().Str("from", from).Str("from-type", type_).Str("to", to).Str("src", src).Str("dst", dst).Str("body", body).Str("reply", next_message.Content).Msg("Handling text message") -}