-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
spoken: package for recording speaking history
- Loading branch information
1 parent
5456e65
commit 4173dc7
Showing
3 changed files
with
200 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |