From 4173dc7fdb073ca428d2307f468928f3647511c9 Mon Sep 17 00:00:00 2001 From: Branden J Brown Date: Fri, 16 Aug 2024 00:23:24 -0500 Subject: [PATCH] spoken: package for recording speaking history --- spoken/schema.sql | 21 ++++++++++ spoken/spoken.go | 91 +++++++++++++++++++++++++++++++++++++++++++ spoken/spoken_test.go | 88 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 spoken/schema.sql create mode 100644 spoken/spoken.go create mode 100644 spoken/spoken_test.go diff --git a/spoken/schema.sql b/spoken/schema.sql new file mode 100644 index 0000000..1bde88f --- /dev/null +++ b/spoken/schema.sql @@ -0,0 +1,21 @@ +CREATE TABLE spoken ( + -- Tag or tenant for the entry. + -- Note this is the speaking tag, not the tenant's learning tag. + tag TEXT NOT NULL, + -- Message text, without emote or effect. + msg TEXT NOT NULL, + -- Trace of message IDs used to generate the message, + -- stored as a JSONB array. + trace BLOB NOT NULL, + -- Message timestamp as nanoseconds from the UNIX epoch. + time INTEGER NOT NULL, + -- Various metadata about the message, stored as a JSONB object. + -- May include: + -- "emote": Emote appended to the message. + -- "effect": Name of the effect applied to the message. + -- "cost": Time in nanoseconds spent generating the message. + meta BLOB NOT NULL +) STRICT; + +-- Covering index for lookup. +CREATE INDEX traces ON spoken (tag, msg, time DESC, trace); diff --git a/spoken/spoken.go b/spoken/spoken.go new file mode 100644 index 0000000..cd8cf90 --- /dev/null +++ b/spoken/spoken.go @@ -0,0 +1,91 @@ +package spoken + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "time" + + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" +) + +// meta is metadata that may be associated with a generated message. +type meta struct { + // Emote is the emote appended to the message. + Emote string `json:"emote,omitempty"` + // Effect is the name of the effect applied to the message. + Effect string `json:"effect,omitempty"` + // Cost is the time in nanoseconds spent generating the message. + Cost int64 `json:"cost,omitempty"` // TODO(zeph): omitzero if go-json-experiment +} + +// Record records a message with its trace and metadata. +func Record[DB *sqlitex.Pool | *sqlite.Conn](ctx context.Context, db DB, tag, message string, trace []string, tm time.Time, cost time.Duration, emote, effect string) error { + var conn *sqlite.Conn + switch db := any(db).(type) { + case *sqlite.Conn: + conn = db + case *sqlitex.Pool: + var err error + conn, err = db.Take(ctx) + defer db.Put(conn) + if err != nil { + return fmt.Errorf("couldn't get conn to record message: %w", err) + } + } + const insert = `INSERT INTO spoken (tag, msg, trace, time, meta) VALUES (:tag, :msg, JSONB(CAST(:trace AS TEXT)), :time, JSONB(CAST(:meta AS TEXT)))` + st, err := conn.Prepare(insert) + if err != nil { + return fmt.Errorf("couldn't prepare statement to record trace: %w", err) + } + tr, err := json.Marshal(trace) // TODO(zeph): go-json-experiment? + if err != nil { + // Should be impossible. Explode loudly. + go panic(fmt.Errorf("spoken: couldn't marshal trace %#v: %w", trace, err)) + } + m := &meta{ + Emote: emote, + Effect: effect, + Cost: cost.Nanoseconds(), + } + md, err := json.Marshal(m) + if err != nil { + // Again, should be impossible. + go panic(fmt.Errorf("spoken: couldn't marshal metadata %#v: %w", m, err)) + } + st.SetText(":tag", tag) + st.SetText(":msg", message) + st.SetBytes(":trace", tr) + st.SetInt64(":time", tm.UnixNano()) + st.SetBytes(":meta", md) + if _, err := st.Step(); err != nil { + return fmt.Errorf("couldn't insert spoken message: ") + } + return nil +} + +//go:embed schema.sql +var schemaSQL string + +// Init initializes an SQLite DB to record generated messages. +func Init[DB *sqlitex.Pool | *sqlite.Conn](ctx context.Context, db DB) error { + var conn *sqlite.Conn + switch db := any(db).(type) { + case *sqlite.Conn: + conn = db + case *sqlitex.Pool: + var err error + conn, err = db.Take(ctx) + defer db.Put(conn) + if err != nil { + return fmt.Errorf("couldn't get conn to record message: %w", err) + } + } + err := sqlitex.ExecuteScript(conn, schemaSQL, nil) + if err != nil { + return fmt.Errorf("couldn't initialize spoken messages schema: %w", err) + } + return nil +} diff --git a/spoken/spoken_test.go b/spoken/spoken_test.go new file mode 100644 index 0000000..d8bd878 --- /dev/null +++ b/spoken/spoken_test.go @@ -0,0 +1,88 @@ +package spoken_test + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "slices" + "sync/atomic" + "testing" + "time" + + "github.com/zephyrtronium/robot/spoken" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" +) + +var dbCount atomic.Int64 + +func testDB(ctx context.Context) *sqlitex.Pool { + k := dbCount.Add(1) + pool, err := sqlitex.NewPool(fmt.Sprintf("file:test-record-%d.db?mode=memory&cache=shared", k), sqlitex.PoolOptions{Flags: sqlite.OpenReadWrite | sqlite.OpenCreate | sqlite.OpenMemory | sqlite.OpenSharedCache | sqlite.OpenURI}) + if err != nil { + panic(err) + } + if err := spoken.Init(ctx, pool); err != nil { + panic(err) + } + return pool +} + +func TestRecord(t *testing.T) { + ctx := context.Background() + db := testDB(ctx) + conn, err := db.Take(ctx) + defer db.Put(conn) + if err != nil { + t.Fatalf("couldn't get conn: %v", err) + } + err = spoken.Record(ctx, db, "kessoku", "bocchi ryo", []string{"1", "2"}, time.Unix(1, 0), time.Second, "xD", "o") + if err != nil { + t.Errorf("couldn't record: %v", err) + } + + opts := sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + tag := stmt.ColumnText(0) + msg := stmt.ColumnText(1) + trace := stmt.ColumnText(2) + tm := stmt.ColumnInt64(3) + meta := stmt.ColumnText(4) + + if tag != "kessoku" { + t.Errorf("wrong tag recorded: want %q, got %q", "kessoku", tag) + } + if msg != "bocchi ryo" { + t.Errorf("wrong message recorded: want %q, got %q", "bocchi ryo", msg) + } + var tr []string + if err := json.Unmarshal([]byte(trace), &tr); err != nil { + t.Errorf("couldn't unmarshal trace from %q: %v", trace, err) + } + if !slices.Equal(tr, []string{"1", "2"}) { + t.Errorf("wrong trace recorded: want %q, got %q from %q", []string{"1", "2"}, tr, trace) + } + if got, want := time.Unix(0, tm), time.Unix(1, 0); got != want { + t.Errorf("wrong time: want %v, got %v", want, got) + } + var md map[string]any + if err := json.Unmarshal([]byte(meta), &md); err != nil { + t.Errorf("couldn't unmarshal metadata from %q: %v", meta, md) + } + want := map[string]any{ + "emote": "xD", + "effect": "o", + "cost": float64(time.Second.Nanoseconds()), + } + if !maps.Equal(md, want) { + t.Errorf("wrong metadata recorded: want %v, got %v from %q", want, md, meta) + } + return nil + }, + } + err = sqlitex.ExecuteTransient(conn, `SELECT tag, msg, JSON(trace), time, JSON(meta) FROM spoken`, &opts) + if err != nil { + t.Errorf("failed to scan: %v", err) + } +}