Skip to content

Commit 2b7c849

Browse files
enriquephlclaude
andcommitted
feat(memory): add nullable category column + plumbing
Migration 0006 adds engine.companion_memories.category TEXT, and MemoryRepo::upsert / MemoryRow are extended to thread the optional classifier tag end-to-end. The raw-turn writer in post_process passes None — populating category is the future classifier extraction step. Schema-first so shipping the classifier later doesn't require a backfill migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 07eb0d6 commit 2b7c849

4 files changed

Lines changed: 93 additions & 7 deletions

File tree

crates/eros-engine-server/src/pipeline/handlers.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,7 @@ mod tests {
608608
None,
609609
"profile fact",
610610
&unit_embedding(11),
611+
None,
611612
)
612613
.await
613614
.unwrap();
@@ -618,6 +619,7 @@ mod tests {
618619
Some(instance_id),
619620
"relationship fact",
620621
&unit_embedding(11),
622+
None,
621623
)
622624
.await
623625
.unwrap();
@@ -645,6 +647,7 @@ mod tests {
645647
None,
646648
&format!("profile-{i}"),
647649
&unit_embedding(100 + i),
650+
None,
648651
)
649652
.await
650653
.unwrap();
@@ -657,6 +660,7 @@ mod tests {
657660
Some(instance_id),
658661
&format!("relationship-{i}"),
659662
&unit_embedding(200 + i),
663+
None,
660664
)
661665
.await
662666
.unwrap();
@@ -684,6 +688,7 @@ mod tests {
684688
None,
685689
"profile target",
686690
&unit_embedding(42),
691+
None,
687692
)
688693
.await
689694
.unwrap();
@@ -695,6 +700,7 @@ mod tests {
695700
None,
696701
&format!("profile decoy-{i}"),
697702
&unit_embedding(300 + i),
703+
None,
698704
)
699705
.await
700706
.unwrap();
@@ -708,6 +714,7 @@ mod tests {
708714
Some(instance_id),
709715
"relationship target",
710716
&unit_embedding(99),
717+
None,
711718
)
712719
.await
713720
.unwrap();
@@ -718,6 +725,7 @@ mod tests {
718725
Some(instance_id),
719726
"relationship decoy",
720727
&unit_embedding(400),
728+
None,
721729
)
722730
.await
723731
.unwrap();

crates/eros-engine-server/src/pipeline/post_process.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,19 @@ async fn embed_and_upsert(
223223
.embed_document(content)
224224
.await
225225
.map_err(|e| format!("voyage embed failed: {e}"))?;
226-
repo.upsert(layer, session_id, user_id, instance_id, content, &embedding)
227-
.await
228-
.map_err(|e| format!("memory insert failed: {e}"))?;
226+
// category=None: this writer dumps raw turns. The classifier extraction
227+
// step (future) will write its own rows with category populated.
228+
repo.upsert(
229+
layer,
230+
session_id,
231+
user_id,
232+
instance_id,
233+
content,
234+
&embedding,
235+
None,
236+
)
237+
.await
238+
.map_err(|e| format!("memory insert failed: {e}"))?;
229239
Ok(())
230240
}
231241

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- SPDX-License-Identifier: AGPL-3.0-only
2+
-- Optional category tag for companion_memories rows.
3+
--
4+
-- Currently NULL on every row — the raw-turn writer in
5+
-- `pipeline::post_process::write_turn` does not classify content.
6+
-- A future extraction step will populate it with values like
7+
-- 'fact' | 'preference' | 'event' | 'emotion' so retrieval can
8+
-- weight or filter by memory type. Schema is added now so that
9+
-- shipping the classifier later doesn't require a backfill migration.
10+
ALTER TABLE engine.companion_memories
11+
ADD COLUMN category TEXT;

crates/eros-engine-store/src/memory.rs

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ pub struct MemoryRow {
4040
pub user_id: Uuid,
4141
pub instance_id: Option<Uuid>,
4242
pub content: String,
43+
/// Optional classifier tag (e.g. `"fact"`, `"preference"`, `"event"`).
44+
/// `None` for rows written by the raw-turn writer; populated once the
45+
/// classifier extraction step lands.
46+
pub category: Option<String>,
4347
pub created_at: DateTime<Utc>,
4448
}
4549

@@ -50,6 +54,9 @@ pub struct MemoryRepo<'a> {
5054
impl<'a> MemoryRepo<'a> {
5155
/// Insert a memory row. For `Profile`, `instance_id` is forced to `None`.
5256
/// For `Relationship`, the caller MUST pass `Some(instance_id)`.
57+
/// `category` is an optional classifier tag — pass `None` from
58+
/// the raw-turn writer; the future extraction step will populate it.
59+
#[allow(clippy::too_many_arguments)] // each arg is a distinct concern
5360
pub async fn upsert(
5461
&self,
5562
layer: MemoryLayer,
@@ -58,6 +65,7 @@ impl<'a> MemoryRepo<'a> {
5865
instance_id: Option<Uuid>,
5966
content: &str,
6067
embedding: &[f32],
68+
category: Option<&str>,
6169
) -> Result<Uuid, sqlx::Error> {
6270
let resolved_instance = match layer {
6371
MemoryLayer::Profile => None,
@@ -67,14 +75,15 @@ impl<'a> MemoryRepo<'a> {
6775

6876
let id: Uuid = sqlx::query_scalar(
6977
"INSERT INTO engine.companion_memories \
70-
(session_id, user_id, instance_id, content, embedding) \
71-
VALUES ($1, $2, $3, $4, $5::vector) RETURNING id",
78+
(session_id, user_id, instance_id, content, embedding, category) \
79+
VALUES ($1, $2, $3, $4, $5::vector, $6) RETURNING id",
7280
)
7381
.bind(session_id)
7482
.bind(user_id)
7583
.bind(resolved_instance)
7684
.bind(content)
7785
.bind(vec_text)
86+
.bind(category)
7887
.fetch_one(self.pool)
7988
.await?;
8089
Ok(id)
@@ -96,7 +105,7 @@ impl<'a> MemoryRepo<'a> {
96105
match instance_id {
97106
Some(pid) => {
98107
sqlx::query_as::<_, MemoryRow>(
99-
"SELECT id, session_id, user_id, instance_id, content, created_at \
108+
"SELECT id, session_id, user_id, instance_id, content, category, created_at \
100109
FROM engine.companion_memories \
101110
WHERE user_id = $1 AND instance_id = $2 \
102111
ORDER BY embedding <=> $3::vector \
@@ -111,7 +120,7 @@ impl<'a> MemoryRepo<'a> {
111120
}
112121
None => {
113122
sqlx::query_as::<_, MemoryRow>(
114-
"SELECT id, session_id, user_id, instance_id, content, created_at \
123+
"SELECT id, session_id, user_id, instance_id, content, category, created_at \
115124
FROM engine.companion_memories \
116125
WHERE user_id = $1 AND instance_id IS NULL \
117126
ORDER BY embedding <=> $2::vector \
@@ -166,6 +175,7 @@ mod tests {
166175
Some(instance_id),
167176
"user lives in shanghai",
168177
&emb,
178+
None,
169179
)
170180
.await
171181
.unwrap();
@@ -195,6 +205,7 @@ mod tests {
195205
Some(instance_id),
196206
"target",
197207
&unit_embedding(42),
208+
None,
198209
)
199210
.await
200211
.unwrap();
@@ -205,6 +216,7 @@ mod tests {
205216
Some(instance_id),
206217
"decoy a",
207218
&unit_embedding(100),
219+
None,
208220
)
209221
.await
210222
.unwrap();
@@ -215,6 +227,7 @@ mod tests {
215227
Some(instance_id),
216228
"decoy b",
217229
&unit_embedding(200),
230+
None,
218231
)
219232
.await
220233
.unwrap();
@@ -227,6 +240,48 @@ mod tests {
227240
assert_eq!(hits[0].id, target_id);
228241
}
229242

243+
#[sqlx::test(migrations = "./migrations")]
244+
async fn category_roundtrips_through_search(pool: PgPool) {
245+
let repo = MemoryRepo { pool: &pool };
246+
let user_id = Uuid::new_v4();
247+
let instance_id = Uuid::new_v4();
248+
let session_id = make_session(&pool, user_id, Some(instance_id)).await;
249+
250+
repo.upsert(
251+
MemoryLayer::Relationship,
252+
session_id,
253+
user_id,
254+
Some(instance_id),
255+
"tagged",
256+
&unit_embedding(33),
257+
Some("preference"),
258+
)
259+
.await
260+
.unwrap();
261+
repo.upsert(
262+
MemoryLayer::Relationship,
263+
session_id,
264+
user_id,
265+
Some(instance_id),
266+
"untagged",
267+
&unit_embedding(34),
268+
None,
269+
)
270+
.await
271+
.unwrap();
272+
273+
let mut hits = repo
274+
.search(user_id, Some(instance_id), &unit_embedding(33), 10)
275+
.await
276+
.unwrap();
277+
hits.sort_by_key(|r| r.content.clone());
278+
assert_eq!(hits.len(), 2);
279+
assert_eq!(hits[0].content, "tagged");
280+
assert_eq!(hits[0].category.as_deref(), Some("preference"));
281+
assert_eq!(hits[1].content, "untagged");
282+
assert_eq!(hits[1].category, None);
283+
}
284+
230285
#[sqlx::test(migrations = "./migrations")]
231286
async fn profile_layer_isolates_from_relationship(pool: PgPool) {
232287
let repo = MemoryRepo { pool: &pool };
@@ -242,6 +297,7 @@ mod tests {
242297
Some(instance_id), // ignored
243298
"profile fact",
244299
&unit_embedding(11),
300+
None,
245301
)
246302
.await
247303
.unwrap();
@@ -253,6 +309,7 @@ mod tests {
253309
Some(instance_id),
254310
"relationship fact",
255311
&unit_embedding(11),
312+
None,
256313
)
257314
.await
258315
.unwrap();

0 commit comments

Comments
 (0)