Merge W9B — kei-chat-store cost Real reinstated

This commit is contained in:
Parfii-bot 2026-04-23 13:37:02 +08:00
commit 9fe780b7ac
4 changed files with 42 additions and 16 deletions

View file

@ -1,22 +1,26 @@
//! kei-chat-store EntitySchema — declarative spec consumed by
//! `kei_entity_store::Store` and its verb templates.
//!
//! Shape (Layer-A convergence, 2026-04-23):
//! Shape (Layer-A convergence, 2026-04-23; cost-column re-migration
//! 2026-04-23 wave 8):
//!
//! - Primary entity = `chat_messages` (INTEGER PK; required by engine).
//! Engine owns: create / get / list / search verbs + FTS reindex.
//! - Bespoke: `chat_sessions` has a TEXT UUID primary key that the
//! engine's `FieldKind::IntegerPk` cannot represent, so its DDL rides
//! the engine's `custom_migrations` slot and its CRUD stays in
//! `sessions.rs` (analogous to kei-task's milestones / deps / graph).
//! - Archive: sessions use a TEXT `status` enum ('active' | 'archived')
//! rather than an INTEGER flag, so the engine `archive` verb is NOT
//! enabled. Session archival stays bespoke.
//! - Per-message `cost` (REAL in legacy) is dropped from `chat_messages`:
//! the engine has no REAL FieldKind and the only consumer is the
//! session-level aggregate `chat_sessions.total_cost`, updated
//! bespoke in `save_message`. No caller reads per-message cost in
//! current tests or the CLI surface.
//! - Bespoke: `chat_sessions` has a TEXT UUID primary key. The engine
//! gained `FieldKind::TextPk` in wave 8 but Store::open currently
//! takes a SINGLE EntitySchema, so a second managed schema for
//! sessions would require engine multi-schema support. Until that
//! lands, the session DDL rides `custom_migrations` and its CRUD
//! stays in `sessions.rs` (analogous to kei-task's milestones / deps
//! / graph). **Known open:** promote chat_sessions to a second
//! EntitySchema (`TextPk` + `TextArchiveEnum`) once engine gains
//! multi-schema support.
//! - Archive: sessions use a TEXT `status` enum ('active' | 'archived').
//! Session archival stays bespoke (same reason as above).
//! - Per-message `cost` (REAL) is restored via
//! `FieldKind::RealDefault(0.0)` — wave-8 addition. Engine-managed
//! INSERT/SELECT, no bespoke SQL needed. Previously dropped when
//! the engine had no REAL kind; re-instated 2026-04-23.
//!
//! FTS column-name change vs pre-convergence shape:
//! legacy fts_chat(message_id, session_id UNINDEXED, content)
@ -34,6 +38,7 @@ static FIELDS: &[FieldDef] = &[
FieldDef::text_nn("content"),
FieldDef::integer("tokens_in"),
FieldDef::integer("tokens_out"),
FieldDef::real_default("cost", 0.0),
FieldDef::created_at(),
];

View file

@ -4,8 +4,8 @@
//! `kei_entity_store::verbs::search` using `CHAT_SCHEMA`. The engine
//! handles FTS5 JOIN + rank ordering; this module maps the generic
//! JSON result back to typed `ChatMessage` rows for legacy callers.
//! Per-message `cost` is not persisted after the convergence (see
//! `schema.rs` note); `cost` is populated as 0.0 on every hit.
//! Per-message `cost` is persisted (engine `RealDefault` field);
//! `row_to_message` reads it back as f64.
use crate::schema::CHAT_SCHEMA;
use crate::sessions::ChatMessage;
@ -32,7 +32,7 @@ fn row_to_message(r: &Value) -> Result<ChatMessage> {
content: r["content"].as_str().unwrap_or("").into(),
tokens_in: r["tokens_in"].as_i64().unwrap_or(0),
tokens_out: r["tokens_out"].as_i64().unwrap_or(0),
cost: 0.0,
cost: r["cost"].as_f64().unwrap_or(0.0),
created_at: r["created_at"].as_i64().unwrap_or(0),
})
}

View file

@ -61,6 +61,7 @@ pub fn save_message(store: &Store, msg: &ChatMessage) -> Result<i64> {
"content": msg.content,
"tokens_in": msg.tokens_in,
"tokens_out": msg.tokens_out,
"cost": msg.cost,
"created_at": msg.created_at,
});
let v = v_create::run(store.conn(), &CHAT_SCHEMA, payload)

View file

@ -62,6 +62,26 @@ fn engine_migration_parity_smoke() {
assert_eq!(sess.message_count, 1);
}
#[test]
fn cost_roundtrips_via_search() {
// Wave-8 re-migration: cost is re-instated as engine-managed
// RealDefault column. The value written via save_message must be
// visible on the ChatMessage returned from search (no longer 0.0).
let s = mk();
let sid = start_session(&s, "demo", "", "").unwrap();
save_message(&s, &ChatMessage {
session_id: sid, role: "user".into(),
content: "rust async tokio bench cost-marker".into(),
tokens_in: 1, tokens_out: 1, cost: 0.00777,
..Default::default()
}).unwrap();
let hits = search(&s, "cost-marker", 10).unwrap();
assert_eq!(hits.len(), 1);
assert!((hits[0].cost - 0.00777).abs() < 1e-9,
"cost should round-trip via engine RealDefault column, got {}",
hits[0].cost);
}
#[test]
fn stats_aggregates() {
let s = mk();