Skip to content

Commit

Permalink
spoken: package for recording speaking history
Browse files Browse the repository at this point in the history
  • Loading branch information
zephyrtronium committed Aug 16, 2024
1 parent 5456e65 commit 4173dc7
Show file tree
Hide file tree
Showing 3 changed files with 200 additions and 0 deletions.
21 changes: 21 additions & 0 deletions spoken/schema.sql
Original file line number Diff line number Diff line change
@@ -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);
91 changes: 91 additions & 0 deletions spoken/spoken.go
Original file line number Diff line number Diff line change
@@ -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
}
88 changes: 88 additions & 0 deletions spoken/spoken_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit 4173dc7

Please sign in to comment.