Working LLM responses and Twilio status tracking

The responses aren't good, but they do exist.
This commit is contained in:
Eli Ribble 2026-01-27 14:29:55 +00:00
parent 407b478637
commit b8e7b9b7fd
No known key found for this signature in database
13 changed files with 497 additions and 287 deletions

View file

@ -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) {

View file

@ -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 {

View file

@ -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
}

View file

@ -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{}

View file

@ -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

View file

@ -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 {

View file

@ -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 {

View file

@ -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';

View file

@ -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;

View file

@ -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),
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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")
}