Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions backend/migrations/059_memories.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- 059: Baaton Memory Layer

CREATE TABLE IF NOT EXISTS memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id TEXT NOT NULL,
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'api', 'ai_chat', 'tldr', 'github', 'slack', 'email', 'memory_store')),
kind TEXT NOT NULL DEFAULT 'fact' CHECK (kind IN ('fact', 'decision', 'learning', 'constraint', 'risk', 'handoff', 'integration', 'note')),
content TEXT NOT NULL CHECK (length(trim(content)) > 0),
tags TEXT[] NOT NULL DEFAULT '{}',
confidence DOUBLE PRECISION NOT NULL DEFAULT 0.80 CHECK (confidence >= 0 AND confidence <= 1),
external_url TEXT,
embedding JSONB,
metadata JSONB NOT NULL DEFAULT '{}',
created_by TEXT,
created_by_name TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_memories_org_project_created ON memories(org_id, project_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_memories_org_kind ON memories(org_id, kind);
CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source);
CREATE INDEX IF NOT EXISTS idx_memories_tags ON memories USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_memories_content_search ON memories USING GIN(to_tsvector('simple', content));

CREATE OR REPLACE FUNCTION set_memories_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_memories_updated_at ON memories;
CREATE TRIGGER trg_memories_updated_at
BEFORE UPDATE ON memories
FOR EACH ROW
EXECUTE FUNCTION set_memories_updated_at();
1 change: 1 addition & 0 deletions backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ async fn main() -> anyhow::Result<()> {
(56, include_str!("../migrations/056_pr_comment_job_type.sql")),
(57, include_str!("../migrations/057_org_signing_keys.sql")),
(58, include_str!("../migrations/058_source_slack.sql")),
(59, include_str!("../migrations/059_memories.sql")),
];

for &(version, sql) in migrations {
Expand Down
32 changes: 32 additions & 0 deletions backend/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,38 @@ pub struct AppendContextField {
pub content: String,
}

// ─── Project Memory ────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Memory {
pub id: Uuid,
pub org_id: String,
pub project_id: Option<Uuid>,
pub source: String,
pub kind: String,
pub content: String,
pub tags: Vec<String>,
pub confidence: f64,
pub external_url: Option<String>,
pub embedding: Option<serde_json::Value>,
pub metadata: serde_json::Value,
pub created_by: Option<String>,
pub created_by_name: Option<String>,
pub updated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
pub struct CreateMemory {
pub kind: Option<String>,
pub content: String,
pub tags: Option<Vec<String>>,
pub confidence: Option<f64>,
pub source: Option<String>,
pub external_url: Option<String>,
pub metadata: Option<serde_json::Value>,
}

// ─── Project Template ──────────────────────────────────

#[derive(Debug, Serialize, Deserialize, FromRow)]
Expand Down
52 changes: 52 additions & 0 deletions backend/src/routes/ai_chat/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ pub(super) async fn build_project_context(
learnings: Option<String>,
}

#[derive(sqlx::FromRow)]
struct MemoryRow {
project_id: uuid::Uuid,
source: String,
kind: String,
content: String,
tags: Vec<String>,
confidence: f64,
created_at: chrono::DateTime<chrono::Utc>,
}

let projects: Vec<ProjectRow> = if project_ids.is_empty() {
sqlx::query_as::<_, ProjectRow>(
"SELECT id, name, prefix FROM projects WHERE org_id = ANY($1::text[]) ORDER BY name ASC LIMIT 20",
Expand Down Expand Up @@ -174,6 +185,24 @@ pub(super) async fn build_project_context(
.await
.unwrap_or_default();

let memories: Vec<MemoryRow> = sqlx::query_as::<_, MemoryRow>(
r#"
SELECT project_id, source, kind, content, tags, confidence, created_at
FROM (
SELECT m.*, row_number() OVER (PARTITION BY project_id ORDER BY confidence DESC, created_at DESC) AS rn
FROM memories m
WHERE m.org_id = ANY($1::text[]) AND m.project_id = ANY($2::uuid[])
) ranked
WHERE rn <= 5
ORDER BY project_id, confidence DESC, created_at DESC
"#,
)
.bind(org_ids)
.bind(&project_uuid_list)
.fetch_all(pool)
.await
.unwrap_or_default();

let mut ctx = String::from("## Projets disponibles\n\n");
for project in &projects {
ctx.push_str(&format!(
Expand Down Expand Up @@ -219,6 +248,29 @@ pub(super) async fn build_project_context(
}
}

let project_memories: Vec<&MemoryRow> = memories
.iter()
.filter(|m| m.project_id == project.id)
.collect();
if !project_memories.is_empty() {
ctx.push_str("- Relevant memories:\n");
for m in project_memories {
let tags = if m.tags.is_empty() {
String::new()
} else {
format!(" tags={}", m.tags.join(","))
};
ctx.push_str(&format!(
" - [{} / {} / {:.2}{} / {}] {}\n",
m.kind,
m.source,
m.confidence,
tags,
m.created_at.format("%Y-%m-%d"),
truncate(&m.content, 500)
));
}
}
ctx.push('\n');
}

Expand Down
Loading