🧠 Add Agent Memory System with PostgreSQL + Qdrant + Cohere
Features: - Three-tier memory architecture (short/mid/long-term) - PostgreSQL schema for conversations, events, memories - Qdrant vector database for semantic search - Cohere embeddings (embed-multilingual-v3.0, 1024 dims) - FastAPI Memory Service with full CRUD - External Secrets integration with Vault - Kubernetes deployment manifests Components: - infrastructure/database/agent-memory-schema.sql - infrastructure/kubernetes/apps/qdrant/ - infrastructure/kubernetes/apps/memory-service/ - services/memory-service/ (FastAPI app) Also includes: - External Secrets Operator - Traefik Ingress Controller - Cert-Manager with Let's Encrypt - ArgoCD for GitOps
This commit is contained in:
257
infrastructure/ansible/playbooks/postgres-ha.yml
Normal file
257
infrastructure/ansible/playbooks/postgres-ha.yml
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# DAARION Network - PostgreSQL HA Setup
|
||||||
|
# Patroni + PgBouncer + pgBackRest + Monitoring
|
||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# POSTGRESQL HA WITH PATRONI
|
||||||
|
# =============================================================================
|
||||||
|
- name: Setup PostgreSQL HA Cluster
|
||||||
|
hosts: database_nodes
|
||||||
|
become: yes
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Patroni
|
||||||
|
patroni_version: "3.2.0"
|
||||||
|
patroni_scope: "daarion-cluster"
|
||||||
|
patroni_namespace: "/daarion"
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
postgres_version: "16"
|
||||||
|
postgres_data_dir: "/var/lib/postgresql/{{ postgres_version }}/main"
|
||||||
|
postgres_config_dir: "/etc/postgresql/{{ postgres_version }}/main"
|
||||||
|
|
||||||
|
# PgBouncer
|
||||||
|
pgbouncer_port: 6432
|
||||||
|
pgbouncer_max_client_conn: 1000
|
||||||
|
pgbouncer_default_pool_size: 50
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
pgbackrest_repo_path: "/var/lib/pgbackrest"
|
||||||
|
pgbackrest_s3_bucket: "daarion-backups"
|
||||||
|
|
||||||
|
# Consul for DCS
|
||||||
|
consul_host: "{{ hostvars[groups['masters'][0]].ansible_host }}"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
# =========================================================================
|
||||||
|
# PREREQUISITES
|
||||||
|
# =========================================================================
|
||||||
|
- name: Add PostgreSQL APT repository
|
||||||
|
shell: |
|
||||||
|
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-keyring.gpg
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/postgresql-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list
|
||||||
|
args:
|
||||||
|
creates: /etc/apt/sources.list.d/pgdg.list
|
||||||
|
|
||||||
|
- name: Update apt cache
|
||||||
|
apt:
|
||||||
|
update_cache: yes
|
||||||
|
|
||||||
|
- name: Install PostgreSQL and dependencies
|
||||||
|
apt:
|
||||||
|
name:
|
||||||
|
- postgresql-{{ postgres_version }}
|
||||||
|
- postgresql-contrib-{{ postgres_version }}
|
||||||
|
- python3-pip
|
||||||
|
- python3-psycopg2
|
||||||
|
- python3-consul
|
||||||
|
- pgbouncer
|
||||||
|
- pgbackrest
|
||||||
|
state: present
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PATRONI INSTALLATION
|
||||||
|
# =========================================================================
|
||||||
|
- name: Install Patroni
|
||||||
|
pip:
|
||||||
|
name:
|
||||||
|
- patroni[consul]=={{ patroni_version }}
|
||||||
|
- python-consul
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Create Patroni directories
|
||||||
|
file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: postgres
|
||||||
|
group: postgres
|
||||||
|
mode: '0750'
|
||||||
|
loop:
|
||||||
|
- /etc/patroni
|
||||||
|
- /var/log/patroni
|
||||||
|
|
||||||
|
- name: Configure Patroni
|
||||||
|
template:
|
||||||
|
src: templates/patroni.yml.j2
|
||||||
|
dest: /etc/patroni/patroni.yml
|
||||||
|
owner: postgres
|
||||||
|
group: postgres
|
||||||
|
mode: '0640'
|
||||||
|
notify: restart patroni
|
||||||
|
|
||||||
|
- name: Create Patroni systemd service
|
||||||
|
copy:
|
||||||
|
dest: /etc/systemd/system/patroni.service
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Patroni PostgreSQL Cluster Manager
|
||||||
|
After=network.target consul.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=postgres
|
||||||
|
Group=postgres
|
||||||
|
ExecStart=/usr/local/bin/patroni /etc/patroni/patroni.yml
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
KillMode=process
|
||||||
|
TimeoutSec=30
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
notify:
|
||||||
|
- reload systemd
|
||||||
|
- restart patroni
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PGBOUNCER
|
||||||
|
# =========================================================================
|
||||||
|
- name: Configure PgBouncer
|
||||||
|
template:
|
||||||
|
src: templates/pgbouncer.ini.j2
|
||||||
|
dest: /etc/pgbouncer/pgbouncer.ini
|
||||||
|
owner: postgres
|
||||||
|
group: postgres
|
||||||
|
mode: '0640'
|
||||||
|
notify: restart pgbouncer
|
||||||
|
|
||||||
|
- name: Configure PgBouncer userlist
|
||||||
|
copy:
|
||||||
|
dest: /etc/pgbouncer/userlist.txt
|
||||||
|
content: |
|
||||||
|
"{{ postgres_user }}" "{{ postgres_password }}"
|
||||||
|
"pgbouncer" "{{ pgbouncer_password | default('pgbouncer_secret') }}"
|
||||||
|
owner: postgres
|
||||||
|
group: postgres
|
||||||
|
mode: '0600'
|
||||||
|
notify: restart pgbouncer
|
||||||
|
|
||||||
|
- name: Enable PgBouncer
|
||||||
|
service:
|
||||||
|
name: pgbouncer
|
||||||
|
enabled: yes
|
||||||
|
state: started
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PGBACKREST
|
||||||
|
# =========================================================================
|
||||||
|
- name: Create pgBackRest directories
|
||||||
|
file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: postgres
|
||||||
|
group: postgres
|
||||||
|
mode: '0750'
|
||||||
|
loop:
|
||||||
|
- "{{ pgbackrest_repo_path }}"
|
||||||
|
- /var/log/pgbackrest
|
||||||
|
|
||||||
|
- name: Configure pgBackRest
|
||||||
|
template:
|
||||||
|
src: templates/pgbackrest.conf.j2
|
||||||
|
dest: /etc/pgbackrest.conf
|
||||||
|
owner: postgres
|
||||||
|
group: postgres
|
||||||
|
mode: '0640'
|
||||||
|
|
||||||
|
- name: Setup backup cron
|
||||||
|
cron:
|
||||||
|
name: "Daily PostgreSQL backup"
|
||||||
|
hour: "2"
|
||||||
|
minute: "0"
|
||||||
|
user: postgres
|
||||||
|
job: "pgbackrest --stanza={{ patroni_scope }} --type=diff backup >> /var/log/pgbackrest/backup.log 2>&1"
|
||||||
|
|
||||||
|
- name: Setup weekly full backup
|
||||||
|
cron:
|
||||||
|
name: "Weekly full PostgreSQL backup"
|
||||||
|
weekday: "0"
|
||||||
|
hour: "3"
|
||||||
|
minute: "0"
|
||||||
|
user: postgres
|
||||||
|
job: "pgbackrest --stanza={{ patroni_scope }} --type=full backup >> /var/log/pgbackrest/backup.log 2>&1"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# MONITORING
|
||||||
|
# =========================================================================
|
||||||
|
- name: Install postgres_exporter
|
||||||
|
shell: |
|
||||||
|
curl -sL https://github.com/prometheus-community/postgres_exporter/releases/download/v0.15.0/postgres_exporter-0.15.0.linux-amd64.tar.gz | tar xz
|
||||||
|
mv postgres_exporter-0.15.0.linux-amd64/postgres_exporter /usr/local/bin/
|
||||||
|
rm -rf postgres_exporter-0.15.0.linux-amd64
|
||||||
|
args:
|
||||||
|
creates: /usr/local/bin/postgres_exporter
|
||||||
|
|
||||||
|
- name: Create postgres_exporter systemd service
|
||||||
|
copy:
|
||||||
|
dest: /etc/systemd/system/postgres_exporter.service
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Prometheus PostgreSQL Exporter
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=postgres
|
||||||
|
Environment="DATA_SOURCE_NAME=postgresql://{{ postgres_user }}:{{ postgres_password }}@localhost:5432/{{ postgres_db }}?sslmode=disable"
|
||||||
|
ExecStart=/usr/local/bin/postgres_exporter --web.listen-address=:9187
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
notify:
|
||||||
|
- reload systemd
|
||||||
|
- restart postgres_exporter
|
||||||
|
|
||||||
|
- name: Enable postgres_exporter
|
||||||
|
service:
|
||||||
|
name: postgres_exporter
|
||||||
|
enabled: yes
|
||||||
|
state: started
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# VERIFICATION
|
||||||
|
# =========================================================================
|
||||||
|
- name: Show PostgreSQL HA status
|
||||||
|
debug:
|
||||||
|
msg: |
|
||||||
|
PostgreSQL HA Setup Complete!
|
||||||
|
|
||||||
|
Components:
|
||||||
|
- Patroni: Cluster management
|
||||||
|
- PgBouncer: Connection pooling (port {{ pgbouncer_port }})
|
||||||
|
- pgBackRest: Backups
|
||||||
|
- postgres_exporter: Metrics (port 9187)
|
||||||
|
|
||||||
|
Connection strings:
|
||||||
|
- Direct: postgresql://{{ postgres_user }}@{{ ansible_host }}:5432/{{ postgres_db }}
|
||||||
|
- Pooled: postgresql://{{ postgres_user }}@{{ ansible_host }}:{{ pgbouncer_port }}/{{ postgres_db }}
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
- name: reload systemd
|
||||||
|
systemd:
|
||||||
|
daemon_reload: yes
|
||||||
|
|
||||||
|
- name: restart patroni
|
||||||
|
service:
|
||||||
|
name: patroni
|
||||||
|
state: restarted
|
||||||
|
|
||||||
|
- name: restart pgbouncer
|
||||||
|
service:
|
||||||
|
name: pgbouncer
|
||||||
|
state: restarted
|
||||||
|
|
||||||
|
- name: restart postgres_exporter
|
||||||
|
service:
|
||||||
|
name: postgres_exporter
|
||||||
|
state: restarted
|
||||||
120
infrastructure/ansible/templates/patroni.yml.j2
Normal file
120
infrastructure/ansible/templates/patroni.yml.j2
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Patroni Configuration for {{ inventory_hostname }}
|
||||||
|
# Generated by Ansible
|
||||||
|
|
||||||
|
scope: {{ patroni_scope }}
|
||||||
|
namespace: {{ patroni_namespace }}
|
||||||
|
name: {{ inventory_hostname }}
|
||||||
|
|
||||||
|
restapi:
|
||||||
|
listen: 0.0.0.0:8008
|
||||||
|
connect_address: {{ ansible_host }}:8008
|
||||||
|
|
||||||
|
consul:
|
||||||
|
host: {{ consul_host }}:8500
|
||||||
|
register_service: true
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
dcs:
|
||||||
|
ttl: 30
|
||||||
|
loop_wait: 10
|
||||||
|
retry_timeout: 10
|
||||||
|
maximum_lag_on_failover: 1048576
|
||||||
|
postgresql:
|
||||||
|
use_pg_rewind: true
|
||||||
|
use_slots: true
|
||||||
|
parameters:
|
||||||
|
# Performance
|
||||||
|
max_connections: 200
|
||||||
|
shared_buffers: 256MB
|
||||||
|
effective_cache_size: 768MB
|
||||||
|
maintenance_work_mem: 64MB
|
||||||
|
checkpoint_completion_target: 0.9
|
||||||
|
wal_buffers: 16MB
|
||||||
|
default_statistics_target: 100
|
||||||
|
random_page_cost: 1.1
|
||||||
|
effective_io_concurrency: 200
|
||||||
|
work_mem: 2621kB
|
||||||
|
huge_pages: off
|
||||||
|
min_wal_size: 1GB
|
||||||
|
max_wal_size: 4GB
|
||||||
|
max_worker_processes: 4
|
||||||
|
max_parallel_workers_per_gather: 2
|
||||||
|
max_parallel_workers: 4
|
||||||
|
max_parallel_maintenance_workers: 2
|
||||||
|
|
||||||
|
# Replication
|
||||||
|
wal_level: replica
|
||||||
|
hot_standby: "on"
|
||||||
|
max_wal_senders: 10
|
||||||
|
max_replication_slots: 10
|
||||||
|
hot_standby_feedback: "on"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_destination: 'stderr'
|
||||||
|
logging_collector: 'on'
|
||||||
|
log_directory: 'log'
|
||||||
|
log_filename: 'postgresql-%Y-%m-%d_%H%M%S.log'
|
||||||
|
log_rotation_age: '1d'
|
||||||
|
log_rotation_size: '100MB'
|
||||||
|
log_min_duration_statement: 1000
|
||||||
|
log_checkpoints: 'on'
|
||||||
|
log_connections: 'on'
|
||||||
|
log_disconnections: 'on'
|
||||||
|
log_lock_waits: 'on'
|
||||||
|
|
||||||
|
# Archive (for pgBackRest)
|
||||||
|
archive_mode: "on"
|
||||||
|
archive_command: 'pgbackrest --stanza={{ patroni_scope }} archive-push %p'
|
||||||
|
|
||||||
|
initdb:
|
||||||
|
- encoding: UTF8
|
||||||
|
- data-checksums
|
||||||
|
|
||||||
|
pg_hba:
|
||||||
|
- host replication replicator 0.0.0.0/0 scram-sha-256
|
||||||
|
- host all all 0.0.0.0/0 scram-sha-256
|
||||||
|
|
||||||
|
users:
|
||||||
|
{{ postgres_user }}:
|
||||||
|
password: {{ postgres_password }}
|
||||||
|
options:
|
||||||
|
- createrole
|
||||||
|
- createdb
|
||||||
|
replicator:
|
||||||
|
password: {{ replicator_password | default('replicator_secret') }}
|
||||||
|
options:
|
||||||
|
- replication
|
||||||
|
|
||||||
|
postgresql:
|
||||||
|
listen: 0.0.0.0:5432
|
||||||
|
connect_address: {{ ansible_host }}:5432
|
||||||
|
data_dir: {{ postgres_data_dir }}
|
||||||
|
bin_dir: /usr/lib/postgresql/{{ postgres_version }}/bin
|
||||||
|
config_dir: {{ postgres_config_dir }}
|
||||||
|
pgpass: /var/lib/postgresql/.pgpass
|
||||||
|
|
||||||
|
authentication:
|
||||||
|
replication:
|
||||||
|
username: replicator
|
||||||
|
password: {{ replicator_password | default('replicator_secret') }}
|
||||||
|
superuser:
|
||||||
|
username: postgres
|
||||||
|
password: {{ postgres_superuser_password | default('postgres_secret') }}
|
||||||
|
rewind:
|
||||||
|
username: rewind
|
||||||
|
password: {{ rewind_password | default('rewind_secret') }}
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
unix_socket_directories: '/var/run/postgresql'
|
||||||
|
|
||||||
|
pg_hba:
|
||||||
|
- local all all peer
|
||||||
|
- host all all 127.0.0.1/32 scram-sha-256
|
||||||
|
- host all all 0.0.0.0/0 scram-sha-256
|
||||||
|
- host replication replicator 0.0.0.0/0 scram-sha-256
|
||||||
|
|
||||||
|
tags:
|
||||||
|
nofailover: false
|
||||||
|
noloadbalance: false
|
||||||
|
clonefrom: false
|
||||||
|
nosync: false
|
||||||
40
infrastructure/ansible/templates/pgbackrest.conf.j2
Normal file
40
infrastructure/ansible/templates/pgbackrest.conf.j2
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# pgBackRest Configuration for {{ inventory_hostname }}
|
||||||
|
# Generated by Ansible
|
||||||
|
|
||||||
|
[global]
|
||||||
|
# Repository
|
||||||
|
repo1-path={{ pgbackrest_repo_path }}
|
||||||
|
repo1-retention-full=2
|
||||||
|
repo1-retention-diff=7
|
||||||
|
|
||||||
|
# S3 (optional - uncomment for cloud backups)
|
||||||
|
# repo2-type=s3
|
||||||
|
# repo2-path=/backup
|
||||||
|
# repo2-s3-bucket={{ pgbackrest_s3_bucket }}
|
||||||
|
# repo2-s3-endpoint=s3.eu-central-1.amazonaws.com
|
||||||
|
# repo2-s3-region=eu-central-1
|
||||||
|
# repo2-s3-key={{ pgbackrest_s3_key | default('') }}
|
||||||
|
# repo2-s3-key-secret={{ pgbackrest_s3_secret | default('') }}
|
||||||
|
# repo2-retention-full=4
|
||||||
|
# repo2-retention-diff=14
|
||||||
|
|
||||||
|
# Compression
|
||||||
|
compress-type=zst
|
||||||
|
compress-level=3
|
||||||
|
|
||||||
|
# Parallel
|
||||||
|
process-max=4
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log-level-console=info
|
||||||
|
log-level-file=detail
|
||||||
|
log-path=/var/log/pgbackrest
|
||||||
|
|
||||||
|
# Archive
|
||||||
|
archive-async=y
|
||||||
|
archive-push-queue-max=4GB
|
||||||
|
|
||||||
|
[{{ patroni_scope }}]
|
||||||
|
pg1-path={{ postgres_data_dir }}
|
||||||
|
pg1-port=5432
|
||||||
|
pg1-user=postgres
|
||||||
44
infrastructure/ansible/templates/pgbouncer.ini.j2
Normal file
44
infrastructure/ansible/templates/pgbouncer.ini.j2
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# PgBouncer Configuration for {{ inventory_hostname }}
|
||||||
|
# Generated by Ansible
|
||||||
|
|
||||||
|
[databases]
|
||||||
|
{{ postgres_db }} = host=127.0.0.1 port=5432 dbname={{ postgres_db }}
|
||||||
|
* = host=127.0.0.1 port=5432
|
||||||
|
|
||||||
|
[pgbouncer]
|
||||||
|
listen_addr = 0.0.0.0
|
||||||
|
listen_port = {{ pgbouncer_port }}
|
||||||
|
unix_socket_dir = /var/run/postgresql
|
||||||
|
|
||||||
|
auth_type = scram-sha-256
|
||||||
|
auth_file = /etc/pgbouncer/userlist.txt
|
||||||
|
|
||||||
|
# Pool settings
|
||||||
|
pool_mode = transaction
|
||||||
|
max_client_conn = {{ pgbouncer_max_client_conn }}
|
||||||
|
default_pool_size = {{ pgbouncer_default_pool_size }}
|
||||||
|
min_pool_size = 10
|
||||||
|
reserve_pool_size = 5
|
||||||
|
reserve_pool_timeout = 3
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
server_connect_timeout = 15
|
||||||
|
server_idle_timeout = 600
|
||||||
|
server_lifetime = 3600
|
||||||
|
client_idle_timeout = 0
|
||||||
|
client_login_timeout = 60
|
||||||
|
query_timeout = 0
|
||||||
|
query_wait_timeout = 120
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_connections = 1
|
||||||
|
log_disconnections = 1
|
||||||
|
log_pooler_errors = 1
|
||||||
|
stats_period = 60
|
||||||
|
|
||||||
|
# Admin
|
||||||
|
admin_users = pgbouncer,{{ postgres_user }}
|
||||||
|
stats_users = pgbouncer,{{ postgres_user }}
|
||||||
|
|
||||||
|
# Security
|
||||||
|
ignore_startup_parameters = extra_float_digits
|
||||||
458
infrastructure/database/agent-memory-schema.sql
Normal file
458
infrastructure/database/agent-memory-schema.sql
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- DAARION Agent Memory System - PostgreSQL Schema
|
||||||
|
-- Version: 1.0.0
|
||||||
|
-- Date: 2026-01-10
|
||||||
|
--
|
||||||
|
-- Трирівнева пам'ять агентів:
|
||||||
|
-- 1. Short-term: conversation_events (робочий буфер)
|
||||||
|
-- 2. Mid-term: thread_summaries (сесійна/тематична)
|
||||||
|
-- 3. Long-term: long_term_memory_items (персональна/проектна)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Extensions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CORE ENTITIES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Organizations (top-level tenant)
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
org_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Workspaces (projects within org)
|
||||||
|
CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
|
workspace_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_workspaces_org ON workspaces(org_id);
|
||||||
|
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
|
||||||
|
external_id VARCHAR(255), -- for SSO/OAuth mapping
|
||||||
|
email VARCHAR(255),
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
preferences JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(org_id, external_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_org ON users(org_id);
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
|
||||||
|
-- Agents
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
agent_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL, -- 'assistant', 'specialist', 'coordinator'
|
||||||
|
model VARCHAR(100), -- 'claude-3-opus', 'gpt-4', etc.
|
||||||
|
system_prompt TEXT,
|
||||||
|
capabilities JSONB DEFAULT '[]', -- ['code', 'search', 'memory', 'tools']
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_agents_org ON agents(org_id);
|
||||||
|
CREATE INDEX idx_agents_type ON agents(type);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CONVERSATION LAYER (Short-term Memory)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Conversation threads
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_threads (
|
||||||
|
thread_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
|
||||||
|
workspace_id UUID REFERENCES workspaces(workspace_id) ON DELETE SET NULL,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
agent_id UUID REFERENCES agents(agent_id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
title VARCHAR(500),
|
||||||
|
status VARCHAR(50) DEFAULT 'active', -- 'active', 'archived', 'completed'
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
tags JSONB DEFAULT '[]',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Stats
|
||||||
|
message_count INTEGER DEFAULT 0,
|
||||||
|
total_tokens INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
last_activity_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
archived_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_threads_org ON conversation_threads(org_id);
|
||||||
|
CREATE INDEX idx_threads_workspace ON conversation_threads(workspace_id);
|
||||||
|
CREATE INDEX idx_threads_user ON conversation_threads(user_id);
|
||||||
|
CREATE INDEX idx_threads_agent ON conversation_threads(agent_id);
|
||||||
|
CREATE INDEX idx_threads_status ON conversation_threads(status);
|
||||||
|
CREATE INDEX idx_threads_last_activity ON conversation_threads(last_activity_at DESC);
|
||||||
|
|
||||||
|
-- Conversation events (Event Log - source of truth)
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_events (
|
||||||
|
event_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
thread_id UUID NOT NULL REFERENCES conversation_threads(thread_id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Event type
|
||||||
|
event_type VARCHAR(50) NOT NULL, -- 'message', 'tool_call', 'tool_result', 'decision', 'summary', 'memory_write', 'memory_retract', 'error'
|
||||||
|
|
||||||
|
-- For messages
|
||||||
|
role VARCHAR(20), -- 'user', 'assistant', 'system', 'tool'
|
||||||
|
content TEXT,
|
||||||
|
|
||||||
|
-- For tool calls
|
||||||
|
tool_name VARCHAR(100),
|
||||||
|
tool_input JSONB,
|
||||||
|
tool_output JSONB,
|
||||||
|
|
||||||
|
-- Structured payload (flexible)
|
||||||
|
payload JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Token tracking
|
||||||
|
token_count INTEGER,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
model_used VARCHAR(100),
|
||||||
|
latency_ms INTEGER,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Ordering
|
||||||
|
sequence_num SERIAL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_events_thread ON conversation_events(thread_id);
|
||||||
|
CREATE INDEX idx_events_type ON conversation_events(event_type);
|
||||||
|
CREATE INDEX idx_events_created ON conversation_events(created_at DESC);
|
||||||
|
CREATE INDEX idx_events_thread_seq ON conversation_events(thread_id, sequence_num);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SUMMARY LAYER (Mid-term Memory)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Thread summaries (rolling compression)
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_summaries (
|
||||||
|
summary_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
thread_id UUID NOT NULL REFERENCES conversation_threads(thread_id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Version tracking
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
-- Summary content
|
||||||
|
summary_text TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Structured state
|
||||||
|
state JSONB DEFAULT '{}', -- {goals: [], decisions: [], open_questions: [], next_steps: [], key_facts: []}
|
||||||
|
|
||||||
|
-- Coverage
|
||||||
|
events_from_seq INTEGER, -- first event sequence included
|
||||||
|
events_to_seq INTEGER, -- last event sequence included
|
||||||
|
events_count INTEGER,
|
||||||
|
|
||||||
|
-- Token info
|
||||||
|
original_tokens INTEGER, -- tokens before compression
|
||||||
|
summary_tokens INTEGER, -- tokens after compression
|
||||||
|
compression_ratio FLOAT,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(thread_id, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_summaries_thread ON thread_summaries(thread_id);
|
||||||
|
CREATE INDEX idx_summaries_thread_version ON thread_summaries(thread_id, version DESC);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- LONG-TERM MEMORY LAYER
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Memory categories enum
|
||||||
|
CREATE TYPE memory_category AS ENUM (
|
||||||
|
'preference', -- user likes/dislikes
|
||||||
|
'identity', -- who the user is
|
||||||
|
'constraint', -- limitations, rules
|
||||||
|
'project_fact', -- project-specific knowledge
|
||||||
|
'relationship', -- connections between entities
|
||||||
|
'skill', -- user capabilities
|
||||||
|
'goal', -- user objectives
|
||||||
|
'context', -- situational info
|
||||||
|
'feedback' -- user corrections/confirmations
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Retention policy enum
|
||||||
|
CREATE TYPE retention_policy AS ENUM (
|
||||||
|
'permanent', -- keep until explicitly deleted
|
||||||
|
'session', -- delete after session
|
||||||
|
'ttl_days', -- delete after N days
|
||||||
|
'until_revoked' -- keep until user revokes
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Long-term memory items
|
||||||
|
CREATE TABLE IF NOT EXISTS long_term_memory_items (
|
||||||
|
memory_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Scope (all nullable for flexibility)
|
||||||
|
org_id UUID REFERENCES organizations(org_id) ON DELETE CASCADE,
|
||||||
|
workspace_id UUID REFERENCES workspaces(workspace_id) ON DELETE SET NULL,
|
||||||
|
user_id UUID REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
agent_id UUID REFERENCES agents(agent_id) ON DELETE SET NULL, -- null = global for user
|
||||||
|
|
||||||
|
-- Content
|
||||||
|
category memory_category NOT NULL,
|
||||||
|
fact_text TEXT NOT NULL, -- atomic statement
|
||||||
|
fact_embedding_id VARCHAR(100), -- reference to Qdrant point ID
|
||||||
|
|
||||||
|
-- Confidence & validation
|
||||||
|
confidence FLOAT DEFAULT 0.8 CHECK (confidence >= 0 AND confidence <= 1),
|
||||||
|
is_verified BOOLEAN DEFAULT false,
|
||||||
|
verification_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Source tracking
|
||||||
|
source_event_id UUID REFERENCES conversation_events(event_id) ON DELETE SET NULL,
|
||||||
|
source_thread_id UUID REFERENCES conversation_threads(thread_id) ON DELETE SET NULL,
|
||||||
|
extraction_method VARCHAR(50), -- 'explicit', 'inferred', 'confirmed', 'imported'
|
||||||
|
|
||||||
|
-- Lifecycle
|
||||||
|
valid_from TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
valid_to TIMESTAMPTZ, -- null = currently valid
|
||||||
|
last_confirmed_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
use_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Privacy & retention
|
||||||
|
is_sensitive BOOLEAN DEFAULT false,
|
||||||
|
retention retention_policy DEFAULT 'until_revoked',
|
||||||
|
ttl_days INTEGER, -- if retention = 'ttl_days'
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
tags JSONB DEFAULT '[]',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for memory retrieval
|
||||||
|
CREATE INDEX idx_memory_org ON long_term_memory_items(org_id);
|
||||||
|
CREATE INDEX idx_memory_workspace ON long_term_memory_items(workspace_id);
|
||||||
|
CREATE INDEX idx_memory_user ON long_term_memory_items(user_id);
|
||||||
|
CREATE INDEX idx_memory_agent ON long_term_memory_items(agent_id);
|
||||||
|
CREATE INDEX idx_memory_category ON long_term_memory_items(category);
|
||||||
|
CREATE INDEX idx_memory_user_agent ON long_term_memory_items(user_id, agent_id);
|
||||||
|
CREATE INDEX idx_memory_valid ON long_term_memory_items(valid_from, valid_to);
|
||||||
|
CREATE INDEX idx_memory_confidence ON long_term_memory_items(confidence DESC);
|
||||||
|
CREATE INDEX idx_memory_created ON long_term_memory_items(created_at DESC);
|
||||||
|
|
||||||
|
-- GIN index for tags search
|
||||||
|
CREATE INDEX idx_memory_tags ON long_term_memory_items USING GIN (tags);
|
||||||
|
|
||||||
|
-- Memory feedback (user corrections)
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_feedback (
|
||||||
|
feedback_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
memory_id UUID NOT NULL REFERENCES long_term_memory_items(memory_id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
action VARCHAR(20) NOT NULL, -- 'confirm', 'reject', 'edit', 'delete'
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
reason TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_feedback_memory ON memory_feedback(memory_id);
|
||||||
|
CREATE INDEX idx_feedback_user ON memory_feedback(user_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- HELPER VIEWS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Active memories for a user (across all agents)
|
||||||
|
CREATE OR REPLACE VIEW v_active_user_memories AS
|
||||||
|
SELECT
|
||||||
|
m.*,
|
||||||
|
a.name as agent_name
|
||||||
|
FROM long_term_memory_items m
|
||||||
|
LEFT JOIN agents a ON m.agent_id = a.agent_id
|
||||||
|
WHERE m.valid_to IS NULL
|
||||||
|
AND m.confidence >= 0.5
|
||||||
|
ORDER BY m.confidence DESC, m.last_used_at DESC NULLS LAST;
|
||||||
|
|
||||||
|
-- Recent conversations with summaries
|
||||||
|
CREATE OR REPLACE VIEW v_recent_conversations AS
|
||||||
|
SELECT
|
||||||
|
t.thread_id,
|
||||||
|
t.title,
|
||||||
|
t.user_id,
|
||||||
|
t.agent_id,
|
||||||
|
t.message_count,
|
||||||
|
t.last_activity_at,
|
||||||
|
s.summary_text,
|
||||||
|
s.state
|
||||||
|
FROM conversation_threads t
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT summary_text, state
|
||||||
|
FROM thread_summaries
|
||||||
|
WHERE thread_id = t.thread_id
|
||||||
|
ORDER BY version DESC
|
||||||
|
LIMIT 1
|
||||||
|
) s ON true
|
||||||
|
WHERE t.status = 'active'
|
||||||
|
ORDER BY t.last_activity_at DESC;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FUNCTIONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Update thread stats after new event
|
||||||
|
CREATE OR REPLACE FUNCTION update_thread_stats()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE conversation_threads
|
||||||
|
SET
|
||||||
|
message_count = message_count + CASE WHEN NEW.event_type = 'message' THEN 1 ELSE 0 END,
|
||||||
|
total_tokens = total_tokens + COALESCE(NEW.token_count, 0),
|
||||||
|
last_activity_at = NOW()
|
||||||
|
WHERE thread_id = NEW.thread_id;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_update_thread_stats
|
||||||
|
AFTER INSERT ON conversation_events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_thread_stats();
|
||||||
|
|
||||||
|
-- Update memory usage stats
|
||||||
|
CREATE OR REPLACE FUNCTION update_memory_usage()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE long_term_memory_items
|
||||||
|
SET
|
||||||
|
use_count = use_count + 1,
|
||||||
|
last_used_at = NOW()
|
||||||
|
WHERE memory_id = NEW.memory_id;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Auto-update updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_organizations_updated
|
||||||
|
BEFORE UPDATE ON organizations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_workspaces_updated
|
||||||
|
BEFORE UPDATE ON workspaces
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_users_updated
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_agents_updated
|
||||||
|
BEFORE UPDATE ON agents
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_memory_updated
|
||||||
|
BEFORE UPDATE ON long_term_memory_items
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INITIAL DATA
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Default organization
|
||||||
|
INSERT INTO organizations (org_id, name, settings)
|
||||||
|
VALUES (
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'DAARION',
|
||||||
|
'{"tier": "enterprise", "features": ["memory", "multi-agent", "knowledge-base"]}'
|
||||||
|
) ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Default workspace
|
||||||
|
INSERT INTO workspaces (workspace_id, org_id, name, description)
|
||||||
|
VALUES (
|
||||||
|
'b0000000-0000-0000-0000-000000000001',
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'MicroDAO',
|
||||||
|
'Main development workspace for DAARION project'
|
||||||
|
) ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Default user (Ivan)
|
||||||
|
INSERT INTO users (user_id, org_id, external_id, display_name, preferences)
|
||||||
|
VALUES (
|
||||||
|
'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'ivan',
|
||||||
|
'Ivan Tytar',
|
||||||
|
'{"language": "uk", "timezone": "Europe/Kyiv"}'
|
||||||
|
) ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Default agents
|
||||||
|
INSERT INTO agents (agent_id, org_id, name, type, model, capabilities) VALUES
|
||||||
|
(
|
||||||
|
'd0000000-0000-0000-0000-000000000001',
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'Claude Assistant',
|
||||||
|
'assistant',
|
||||||
|
'claude-3-opus',
|
||||||
|
'["code", "memory", "tools", "search"]'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'd0000000-0000-0000-0000-000000000002',
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'Code Specialist',
|
||||||
|
'specialist',
|
||||||
|
'claude-3-opus',
|
||||||
|
'["code", "review", "refactor"]'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'd0000000-0000-0000-0000-000000000003',
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'DevOps Agent',
|
||||||
|
'specialist',
|
||||||
|
'claude-3-opus',
|
||||||
|
'["infrastructure", "deployment", "monitoring"]'
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- COMMENTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
COMMENT ON TABLE conversation_events IS 'Event log for all conversation activities - source of truth';
|
||||||
|
COMMENT ON TABLE thread_summaries IS 'Rolling summaries for context compression (mid-term memory)';
|
||||||
|
COMMENT ON TABLE long_term_memory_items IS 'Persistent facts about users/projects (long-term memory)';
|
||||||
|
COMMENT ON COLUMN long_term_memory_items.fact_text IS 'Atomic statement - one fact per row';
|
||||||
|
COMMENT ON COLUMN long_term_memory_items.confidence IS 'Confidence score 0-1, increases with confirmations';
|
||||||
|
COMMENT ON COLUMN long_term_memory_items.fact_embedding_id IS 'Reference to Qdrant vector point ID';
|
||||||
105
infrastructure/kubernetes/apps/memory-service/deployment.yaml
Normal file
105
infrastructure/kubernetes/apps/memory-service/deployment.yaml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
# DAARION Memory Service
|
||||||
|
# Agent memory management with PostgreSQL + Qdrant + Cohere
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: memory-service
|
||||||
|
namespace: daarion
|
||||||
|
labels:
|
||||||
|
app: memory-service
|
||||||
|
component: memory
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: memory-service
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: memory-service
|
||||||
|
component: memory
|
||||||
|
spec:
|
||||||
|
nodeSelector:
|
||||||
|
node-role.kubernetes.io/control-plane: "true"
|
||||||
|
containers:
|
||||||
|
- name: memory-service
|
||||||
|
image: ghcr.io/ivantytar/memory-service:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
name: http
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: memory-service-secrets
|
||||||
|
env:
|
||||||
|
- name: MEMORY_DEBUG
|
||||||
|
value: "false"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: memory-service
|
||||||
|
namespace: daarion
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: memory-service
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8000
|
||||||
|
targetPort: 8000
|
||||||
|
type: ClusterIP
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: memory-service-external
|
||||||
|
namespace: daarion
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: memory-service
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8000
|
||||||
|
targetPort: 8000
|
||||||
|
nodePort: 30800
|
||||||
|
type: NodePort
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: memory-service-ingress
|
||||||
|
namespace: daarion
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: memory.daarion.local
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: memory-service
|
||||||
|
port:
|
||||||
|
number: 8000
|
||||||
133
infrastructure/kubernetes/apps/qdrant/deployment.yaml
Normal file
133
infrastructure/kubernetes/apps/qdrant/deployment.yaml
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
# DAARION Qdrant Vector Database
|
||||||
|
# For semantic search in agent memory system
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: qdrant
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: qdrant-storage
|
||||||
|
namespace: qdrant
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
storageClassName: local-path
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: qdrant-config
|
||||||
|
namespace: qdrant
|
||||||
|
data:
|
||||||
|
config.yaml: |
|
||||||
|
log_level: INFO
|
||||||
|
|
||||||
|
storage:
|
||||||
|
storage_path: /qdrant/storage
|
||||||
|
snapshots_path: /qdrant/snapshots
|
||||||
|
|
||||||
|
service:
|
||||||
|
http_port: 6333
|
||||||
|
grpc_port: 6334
|
||||||
|
|
||||||
|
cluster:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
telemetry_disabled: true
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: qdrant
|
||||||
|
namespace: qdrant
|
||||||
|
labels:
|
||||||
|
app: qdrant
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: qdrant
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: qdrant
|
||||||
|
spec:
|
||||||
|
nodeSelector:
|
||||||
|
node-role.kubernetes.io/control-plane: "true"
|
||||||
|
containers:
|
||||||
|
- name: qdrant
|
||||||
|
image: qdrant/qdrant:v1.7.4
|
||||||
|
ports:
|
||||||
|
- containerPort: 6333
|
||||||
|
name: http
|
||||||
|
- containerPort: 6334
|
||||||
|
name: grpc
|
||||||
|
volumeMounts:
|
||||||
|
- name: storage
|
||||||
|
mountPath: /qdrant/storage
|
||||||
|
- name: config
|
||||||
|
mountPath: /qdrant/config
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "250m"
|
||||||
|
limits:
|
||||||
|
memory: "2Gi"
|
||||||
|
cpu: "1"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 6333
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: 6333
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
volumes:
|
||||||
|
- name: storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: qdrant-storage
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: qdrant-config
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: qdrant
|
||||||
|
namespace: qdrant
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: qdrant
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 6333
|
||||||
|
targetPort: 6333
|
||||||
|
- name: grpc
|
||||||
|
port: 6334
|
||||||
|
targetPort: 6334
|
||||||
|
type: ClusterIP
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: qdrant-external
|
||||||
|
namespace: qdrant
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: qdrant
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 6333
|
||||||
|
targetPort: 6333
|
||||||
|
nodePort: 30633
|
||||||
|
type: NodePort
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
|
# DAARION Memory Service
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Встановлюємо системні залежності
|
# Install dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
gcc \
|
|
||||||
postgresql-client \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Копіюємо requirements та встановлюємо залежності
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Копіюємо код
|
# Copy application
|
||||||
COPY . .
|
COPY app/ ./app/
|
||||||
|
|
||||||
# Відкриваємо порт
|
# Environment
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Run
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Запускаємо додаток
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
# Memory Service for DAARION.city
|
"""DAARION Memory Service"""
|
||||||
|
|||||||
56
services/memory-service/app/config.py
Normal file
56
services/memory-service/app/config.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
DAARION Memory Service Configuration
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings"""
|
||||||
|
|
||||||
|
# Service
|
||||||
|
service_name: str = "memory-service"
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
postgres_host: str = "daarion-pooler.daarion"
|
||||||
|
postgres_port: int = 5432
|
||||||
|
postgres_user: str = "daarion"
|
||||||
|
postgres_password: str = "DaarionDB2026!"
|
||||||
|
postgres_db: str = "daarion_main"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def postgres_url(self) -> str:
|
||||||
|
return f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||||
|
|
||||||
|
# Qdrant
|
||||||
|
qdrant_host: str = "qdrant.qdrant"
|
||||||
|
qdrant_port: int = 6333
|
||||||
|
qdrant_collection_memories: str = "memories"
|
||||||
|
qdrant_collection_messages: str = "messages"
|
||||||
|
|
||||||
|
# Cohere (embeddings)
|
||||||
|
cohere_api_key: str = "nOdOXnuepLku2ipJWpe6acWgAsJCsDhMO0RnaEJB"
|
||||||
|
cohere_model: str = "embed-multilingual-v3.0" # 1024 dimensions
|
||||||
|
embedding_dimensions: int = 1024
|
||||||
|
|
||||||
|
# Memory settings
|
||||||
|
short_term_window_messages: int = 20
|
||||||
|
short_term_window_minutes: int = 60
|
||||||
|
summary_trigger_tokens: int = 4000
|
||||||
|
summary_target_tokens: int = 500
|
||||||
|
retrieval_top_k: int = 10
|
||||||
|
|
||||||
|
# Confidence thresholds
|
||||||
|
memory_min_confidence: float = 0.5
|
||||||
|
memory_confirm_boost: float = 0.1
|
||||||
|
memory_reject_penalty: float = 0.3
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_prefix = "MEMORY_"
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
430
services/memory-service/app/database.py
Normal file
430
services/memory-service/app/database.py
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
"""
|
||||||
|
DAARION Memory Service - PostgreSQL Database Layer
|
||||||
|
"""
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
from datetime import datetime
|
||||||
|
import structlog
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from .models import EventType, MessageRole, MemoryCategory, RetentionPolicy, FeedbackAction
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""PostgreSQL database operations"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.pool: Optional[asyncpg.Pool] = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Connect to database"""
|
||||||
|
self.pool = await asyncpg.create_pool(
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
database=settings.postgres_db,
|
||||||
|
min_size=5,
|
||||||
|
max_size=20
|
||||||
|
)
|
||||||
|
logger.info("database_connected")
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Disconnect from database"""
|
||||||
|
if self.pool:
|
||||||
|
await self.pool.close()
|
||||||
|
logger.info("database_disconnected")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# THREADS
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def create_thread(
|
||||||
|
self,
|
||||||
|
org_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
workspace_id: Optional[UUID] = None,
|
||||||
|
agent_id: Optional[UUID] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
tags: List[str] = [],
|
||||||
|
metadata: dict = {}
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Create new conversation thread"""
|
||||||
|
thread_id = uuid4()
|
||||||
|
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
INSERT INTO conversation_threads
|
||||||
|
(thread_id, org_id, workspace_id, user_id, agent_id, title, tags, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *
|
||||||
|
""", thread_id, org_id, workspace_id, user_id, agent_id, title, tags, metadata)
|
||||||
|
|
||||||
|
logger.info("thread_created", thread_id=str(thread_id))
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def get_thread(self, thread_id: UUID) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get thread by ID"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
SELECT * FROM conversation_threads WHERE thread_id = $1
|
||||||
|
""", thread_id)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def list_threads(
|
||||||
|
self,
|
||||||
|
org_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
workspace_id: Optional[UUID] = None,
|
||||||
|
agent_id: Optional[UUID] = None,
|
||||||
|
limit: int = 20
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""List threads for user"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
query = """
|
||||||
|
SELECT * FROM conversation_threads
|
||||||
|
WHERE org_id = $1 AND user_id = $2 AND status = 'active'
|
||||||
|
"""
|
||||||
|
params = [org_id, user_id]
|
||||||
|
|
||||||
|
if workspace_id:
|
||||||
|
query += f" AND workspace_id = ${len(params) + 1}"
|
||||||
|
params.append(workspace_id)
|
||||||
|
if agent_id:
|
||||||
|
query += f" AND agent_id = ${len(params) + 1}"
|
||||||
|
params.append(agent_id)
|
||||||
|
|
||||||
|
query += f" ORDER BY last_activity_at DESC LIMIT ${len(params) + 1}"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
rows = await conn.fetch(query, *params)
|
||||||
|
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# EVENTS
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def add_event(
|
||||||
|
self,
|
||||||
|
thread_id: UUID,
|
||||||
|
event_type: EventType,
|
||||||
|
role: Optional[MessageRole] = None,
|
||||||
|
content: Optional[str] = None,
|
||||||
|
tool_name: Optional[str] = None,
|
||||||
|
tool_input: Optional[dict] = None,
|
||||||
|
tool_output: Optional[dict] = None,
|
||||||
|
payload: dict = {},
|
||||||
|
token_count: Optional[int] = None,
|
||||||
|
model_used: Optional[str] = None,
|
||||||
|
latency_ms: Optional[int] = None,
|
||||||
|
metadata: dict = {}
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Add event to conversation"""
|
||||||
|
event_id = uuid4()
|
||||||
|
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
INSERT INTO conversation_events
|
||||||
|
(event_id, thread_id, event_type, role, content, tool_name,
|
||||||
|
tool_input, tool_output, payload, token_count, model_used,
|
||||||
|
latency_ms, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *
|
||||||
|
""", event_id, thread_id, event_type.value,
|
||||||
|
role.value if role else None, content, tool_name,
|
||||||
|
tool_input, tool_output, payload, token_count, model_used,
|
||||||
|
latency_ms, metadata)
|
||||||
|
|
||||||
|
logger.info("event_added", event_id=str(event_id), type=event_type.value)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def get_events(
|
||||||
|
self,
|
||||||
|
thread_id: UUID,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get events for thread"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT * FROM conversation_events
|
||||||
|
WHERE thread_id = $1
|
||||||
|
ORDER BY sequence_num DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
""", thread_id, limit, offset)
|
||||||
|
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
async def get_events_for_summary(
|
||||||
|
self,
|
||||||
|
thread_id: UUID,
|
||||||
|
after_seq: Optional[int] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get events for summarization"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
if after_seq:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT * FROM conversation_events
|
||||||
|
WHERE thread_id = $1 AND sequence_num > $2
|
||||||
|
ORDER BY sequence_num ASC
|
||||||
|
""", thread_id, after_seq)
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT * FROM conversation_events
|
||||||
|
WHERE thread_id = $1
|
||||||
|
ORDER BY sequence_num ASC
|
||||||
|
""", thread_id)
|
||||||
|
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# MEMORIES
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def create_memory(
|
||||||
|
self,
|
||||||
|
org_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
category: MemoryCategory,
|
||||||
|
fact_text: str,
|
||||||
|
workspace_id: Optional[UUID] = None,
|
||||||
|
agent_id: Optional[UUID] = None,
|
||||||
|
confidence: float = 0.8,
|
||||||
|
source_event_id: Optional[UUID] = None,
|
||||||
|
source_thread_id: Optional[UUID] = None,
|
||||||
|
extraction_method: str = "explicit",
|
||||||
|
is_sensitive: bool = False,
|
||||||
|
retention: RetentionPolicy = RetentionPolicy.UNTIL_REVOKED,
|
||||||
|
ttl_days: Optional[int] = None,
|
||||||
|
tags: List[str] = [],
|
||||||
|
metadata: dict = {}
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Create long-term memory item"""
|
||||||
|
memory_id = uuid4()
|
||||||
|
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
INSERT INTO long_term_memory_items
|
||||||
|
(memory_id, org_id, workspace_id, user_id, agent_id, category,
|
||||||
|
fact_text, confidence, source_event_id, source_thread_id,
|
||||||
|
extraction_method, is_sensitive, retention, ttl_days, tags, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
|
RETURNING *
|
||||||
|
""", memory_id, org_id, workspace_id, user_id, agent_id, category.value,
|
||||||
|
fact_text, confidence, source_event_id, source_thread_id,
|
||||||
|
extraction_method, is_sensitive, retention.value, ttl_days, tags, metadata)
|
||||||
|
|
||||||
|
logger.info("memory_created", memory_id=str(memory_id), category=category.value)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def get_memory(self, memory_id: UUID) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get memory by ID"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
SELECT * FROM long_term_memory_items
|
||||||
|
WHERE memory_id = $1 AND valid_to IS NULL
|
||||||
|
""", memory_id)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def list_memories(
|
||||||
|
self,
|
||||||
|
org_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
agent_id: Optional[UUID] = None,
|
||||||
|
workspace_id: Optional[UUID] = None,
|
||||||
|
category: Optional[MemoryCategory] = None,
|
||||||
|
include_global: bool = True,
|
||||||
|
limit: int = 50
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""List memories for user"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
query = """
|
||||||
|
SELECT * FROM long_term_memory_items
|
||||||
|
WHERE org_id = $1 AND user_id = $2 AND valid_to IS NULL
|
||||||
|
AND confidence >= $3
|
||||||
|
"""
|
||||||
|
params = [org_id, user_id, settings.memory_min_confidence]
|
||||||
|
|
||||||
|
if workspace_id:
|
||||||
|
query += f" AND (workspace_id = ${len(params) + 1} OR workspace_id IS NULL)"
|
||||||
|
params.append(workspace_id)
|
||||||
|
|
||||||
|
if agent_id:
|
||||||
|
if include_global:
|
||||||
|
query += f" AND (agent_id = ${len(params) + 1} OR agent_id IS NULL)"
|
||||||
|
else:
|
||||||
|
query += f" AND agent_id = ${len(params) + 1}"
|
||||||
|
params.append(agent_id)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query += f" AND category = ${len(params) + 1}"
|
||||||
|
params.append(category.value)
|
||||||
|
|
||||||
|
query += f" ORDER BY confidence DESC, last_used_at DESC NULLS LAST LIMIT ${len(params) + 1}"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
rows = await conn.fetch(query, *params)
|
||||||
|
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
async def update_memory_embedding_id(self, memory_id: UUID, embedding_id: str):
|
||||||
|
"""Update memory with Qdrant point ID"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE long_term_memory_items
|
||||||
|
SET fact_embedding_id = $2
|
||||||
|
WHERE memory_id = $1
|
||||||
|
""", memory_id, embedding_id)
|
||||||
|
|
||||||
|
async def update_memory_confidence(
|
||||||
|
self,
|
||||||
|
memory_id: UUID,
|
||||||
|
confidence: float,
|
||||||
|
verified: bool = False
|
||||||
|
):
|
||||||
|
"""Update memory confidence"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE long_term_memory_items
|
||||||
|
SET confidence = $2,
|
||||||
|
is_verified = CASE WHEN $3 THEN true ELSE is_verified END,
|
||||||
|
verification_count = verification_count + CASE WHEN $3 THEN 1 ELSE 0 END,
|
||||||
|
last_confirmed_at = CASE WHEN $3 THEN NOW() ELSE last_confirmed_at END
|
||||||
|
WHERE memory_id = $1
|
||||||
|
""", memory_id, confidence, verified)
|
||||||
|
|
||||||
|
async def update_memory_text(self, memory_id: UUID, new_text: str):
|
||||||
|
"""Update memory text"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE long_term_memory_items
|
||||||
|
SET fact_text = $2
|
||||||
|
WHERE memory_id = $1
|
||||||
|
""", memory_id, new_text)
|
||||||
|
|
||||||
|
async def invalidate_memory(self, memory_id: UUID):
|
||||||
|
"""Mark memory as invalid (soft delete)"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE long_term_memory_items
|
||||||
|
SET valid_to = NOW()
|
||||||
|
WHERE memory_id = $1
|
||||||
|
""", memory_id)
|
||||||
|
logger.info("memory_invalidated", memory_id=str(memory_id))
|
||||||
|
|
||||||
|
async def increment_memory_usage(self, memory_id: UUID):
|
||||||
|
"""Increment memory usage counter"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE long_term_memory_items
|
||||||
|
SET use_count = use_count + 1, last_used_at = NOW()
|
||||||
|
WHERE memory_id = $1
|
||||||
|
""", memory_id)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# FEEDBACK
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def add_memory_feedback(
|
||||||
|
self,
|
||||||
|
memory_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
action: FeedbackAction,
|
||||||
|
old_value: Optional[str] = None,
|
||||||
|
new_value: Optional[str] = None,
|
||||||
|
reason: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""Record user feedback on memory"""
|
||||||
|
feedback_id = uuid4()
|
||||||
|
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO memory_feedback
|
||||||
|
(feedback_id, memory_id, user_id, action, old_value, new_value, reason)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
""", feedback_id, memory_id, user_id, action.value, old_value, new_value, reason)
|
||||||
|
|
||||||
|
logger.info("feedback_recorded", memory_id=str(memory_id), action=action.value)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# SUMMARIES
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def create_summary(
|
||||||
|
self,
|
||||||
|
thread_id: UUID,
|
||||||
|
summary_text: str,
|
||||||
|
state: dict,
|
||||||
|
events_from_seq: int,
|
||||||
|
events_to_seq: int,
|
||||||
|
events_count: int,
|
||||||
|
original_tokens: Optional[int] = None,
|
||||||
|
summary_tokens: Optional[int] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Create thread summary"""
|
||||||
|
summary_id = uuid4()
|
||||||
|
|
||||||
|
# Get next version
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
version_row = await conn.fetchrow("""
|
||||||
|
SELECT COALESCE(MAX(version), 0) + 1 as next_version
|
||||||
|
FROM thread_summaries WHERE thread_id = $1
|
||||||
|
""", thread_id)
|
||||||
|
version = version_row["next_version"]
|
||||||
|
|
||||||
|
compression_ratio = None
|
||||||
|
if original_tokens and summary_tokens:
|
||||||
|
compression_ratio = summary_tokens / original_tokens
|
||||||
|
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
INSERT INTO thread_summaries
|
||||||
|
(summary_id, thread_id, version, summary_text, state,
|
||||||
|
events_from_seq, events_to_seq, events_count,
|
||||||
|
original_tokens, summary_tokens, compression_ratio)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *
|
||||||
|
""", summary_id, thread_id, version, summary_text, state,
|
||||||
|
events_from_seq, events_to_seq, events_count,
|
||||||
|
original_tokens, summary_tokens, compression_ratio)
|
||||||
|
|
||||||
|
logger.info("summary_created", summary_id=str(summary_id), version=version)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def get_latest_summary(self, thread_id: UUID) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get latest summary for thread"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
SELECT * FROM thread_summaries
|
||||||
|
WHERE thread_id = $1
|
||||||
|
ORDER BY version DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", thread_id)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# STATS
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def get_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get database statistics"""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
threads = await conn.fetchval("SELECT COUNT(*) FROM conversation_threads")
|
||||||
|
events = await conn.fetchval("SELECT COUNT(*) FROM conversation_events")
|
||||||
|
memories = await conn.fetchval("SELECT COUNT(*) FROM long_term_memory_items WHERE valid_to IS NULL")
|
||||||
|
summaries = await conn.fetchval("SELECT COUNT(*) FROM thread_summaries")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"threads": threads,
|
||||||
|
"events": events,
|
||||||
|
"active_memories": memories,
|
||||||
|
"summaries": summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
db = Database()
|
||||||
86
services/memory-service/app/embedding.py
Normal file
86
services/memory-service/app/embedding.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
DAARION Memory Service - Embedding Layer (Cohere)
|
||||||
|
"""
|
||||||
|
import cohere
|
||||||
|
from typing import List
|
||||||
|
import structlog
|
||||||
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Initialize Cohere client
|
||||||
|
co = cohere.Client(settings.cohere_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=1, max=10)
|
||||||
|
)
|
||||||
|
async def get_embeddings(
|
||||||
|
texts: List[str],
|
||||||
|
input_type: str = "search_document"
|
||||||
|
) -> List[List[float]]:
|
||||||
|
"""
|
||||||
|
Get embeddings from Cohere API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texts: List of texts to embed
|
||||||
|
input_type: "search_document" for indexing, "search_query" for queries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of embedding vectors (1024 dimensions for embed-multilingual-v3.0)
|
||||||
|
"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info("generating_embeddings", count=len(texts), input_type=input_type)
|
||||||
|
|
||||||
|
response = co.embed(
|
||||||
|
texts=texts,
|
||||||
|
model=settings.cohere_model,
|
||||||
|
input_type=input_type,
|
||||||
|
truncate="END"
|
||||||
|
)
|
||||||
|
|
||||||
|
embeddings = response.embeddings
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"embeddings_generated",
|
||||||
|
count=len(embeddings),
|
||||||
|
dimensions=len(embeddings[0]) if embeddings else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return embeddings
|
||||||
|
|
||||||
|
|
||||||
|
async def get_query_embedding(query: str) -> List[float]:
|
||||||
|
"""Get embedding for a search query"""
|
||||||
|
embeddings = await get_embeddings([query], input_type="search_query")
|
||||||
|
return embeddings[0] if embeddings else []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_document_embeddings(texts: List[str]) -> List[List[float]]:
|
||||||
|
"""Get embeddings for documents (memories, summaries)"""
|
||||||
|
return await get_embeddings(texts, input_type="search_document")
|
||||||
|
|
||||||
|
|
||||||
|
# Batch processing for large sets
|
||||||
|
async def batch_embed(
|
||||||
|
texts: List[str],
|
||||||
|
input_type: str = "search_document",
|
||||||
|
batch_size: int = 96 # Cohere limit
|
||||||
|
) -> List[List[float]]:
|
||||||
|
"""
|
||||||
|
Embed large number of texts in batches.
|
||||||
|
"""
|
||||||
|
all_embeddings = []
|
||||||
|
|
||||||
|
for i in range(0, len(texts), batch_size):
|
||||||
|
batch = texts[i:i + batch_size]
|
||||||
|
embeddings = await get_embeddings(batch, input_type)
|
||||||
|
all_embeddings.extend(embeddings)
|
||||||
|
|
||||||
|
return all_embeddings
|
||||||
@@ -1,443 +1,483 @@
|
|||||||
"""
|
"""
|
||||||
Memory Service - FastAPI додаток
|
DAARION Memory Service - FastAPI Application
|
||||||
Підтримує: user_facts, dialog_summaries, agent_memory_events
|
|
||||||
Інтеграція з token-gate через RBAC
|
Трирівнева пам'ять агентів:
|
||||||
|
- Short-term: conversation events (робочий буфер)
|
||||||
|
- Mid-term: thread summaries (сесійна/тематична)
|
||||||
|
- Long-term: memory items (персональна/проектна)
|
||||||
"""
|
"""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
import os
|
from typing import List, Optional
|
||||||
from typing import Optional, List, Dict, Any
|
from uuid import UUID
|
||||||
from datetime import datetime
|
import structlog
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Query, Header
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from app.models import Base, UserFact, DialogSummary, AgentMemoryEvent
|
from .config import get_settings
|
||||||
from app.schemas import (
|
from .models import (
|
||||||
UserFactCreate, UserFactUpdate, UserFactResponse, UserFactUpsertRequest, UserFactUpsertResponse,
|
CreateThreadRequest, AddEventRequest, CreateMemoryRequest,
|
||||||
DialogSummaryCreate, DialogSummaryResponse, DialogSummaryListResponse,
|
MemoryFeedbackRequest, RetrievalRequest, SummaryRequest,
|
||||||
AgentMemoryEventCreate, AgentMemoryEventResponse, AgentMemoryEventListResponse,
|
ThreadResponse, EventResponse, MemoryResponse,
|
||||||
TokenGateCheck, TokenGateCheckResponse
|
SummaryResponse, RetrievalResponse, RetrievalResult,
|
||||||
)
|
ContextResponse, MemoryCategory, FeedbackAction
|
||||||
from app.crud import (
|
|
||||||
get_user_fact, get_user_facts, create_user_fact, update_user_fact,
|
|
||||||
upsert_user_fact, delete_user_fact, get_user_facts_by_token_gate,
|
|
||||||
create_dialog_summary, get_dialog_summaries, get_dialog_summary, delete_dialog_summary,
|
|
||||||
create_agent_memory_event, get_agent_memory_events, delete_agent_memory_event
|
|
||||||
)
|
)
|
||||||
|
from .vector_store import vector_store
|
||||||
|
from .database import db
|
||||||
|
|
||||||
# ========== Configuration ==========
|
logger = structlog.get_logger()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
DATABASE_URL = os.getenv(
|
|
||||||
"DATABASE_URL",
|
|
||||||
"sqlite:///./memory.db" # SQLite для розробки, PostgreSQL для продакшену
|
|
||||||
)
|
|
||||||
|
|
||||||
# Створюємо engine та sessionmaker
|
@asynccontextmanager
|
||||||
engine = create_engine(DATABASE_URL)
|
async def lifespan(app: FastAPI):
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
"""Startup and shutdown events"""
|
||||||
|
# Startup
|
||||||
|
logger.info("starting_memory_service")
|
||||||
|
await db.connect()
|
||||||
|
await vector_store.initialize()
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
await db.disconnect()
|
||||||
|
logger.info("memory_service_stopped")
|
||||||
|
|
||||||
# Створюємо таблиці (для dev, в продакшені використовуйте міграції)
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
# ========== FastAPI App ==========
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Memory Service",
|
title="DAARION Memory Service",
|
||||||
description="Сервіс пам'яті для MicroDAO: user_facts, dialog_summaries, agent_memory_events",
|
description="Agent memory management with PostgreSQL + Qdrant + Cohere",
|
||||||
version="1.0.0"
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS middleware
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # В продакшені обмежте це
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========== Dependencies ==========
|
|
||||||
|
|
||||||
def get_db():
|
# ============================================================================
|
||||||
"""Dependency для отримання DB сесії"""
|
# HEALTH
|
||||||
db = SessionLocal()
|
# ============================================================================
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def verify_token(authorization: Optional[str] = Header(None)) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Перевірка JWT токену (заглушка)
|
|
||||||
В продакшені інтегруйте з вашою системою авторизації
|
|
||||||
"""
|
|
||||||
if not authorization:
|
|
||||||
raise HTTPException(status_code=401, detail="Missing authorization header")
|
|
||||||
|
|
||||||
# Заглушка: в реальності перевіряйте JWT
|
|
||||||
# token = authorization.replace("Bearer ", "")
|
|
||||||
# user_id = verify_jwt_token(token)
|
|
||||||
# return user_id
|
|
||||||
|
|
||||||
# Для тестування повертаємо user_id з заголовка
|
|
||||||
return "u_test" # TODO: реалізувати реальну перевірку
|
|
||||||
|
|
||||||
|
|
||||||
async def check_token_gate(
|
|
||||||
user_id: str,
|
|
||||||
token_requirements: dict,
|
|
||||||
db: Session
|
|
||||||
) -> TokenGateCheckResponse:
|
|
||||||
"""
|
|
||||||
Перевірка токен-гейту (інтеграція з RBAC/Wallet Service)
|
|
||||||
Заглушка - в продакшені викликайте ваш PDP/Wallet Service
|
|
||||||
"""
|
|
||||||
# TODO: Інтегрувати з:
|
|
||||||
# - PDP Service для перевірки capabilities
|
|
||||||
# - Wallet Service для перевірки балансів
|
|
||||||
# - RBAC для перевірки ролей
|
|
||||||
|
|
||||||
# Приклад логіки:
|
|
||||||
# if "token" in token_requirements:
|
|
||||||
# token_type = token_requirements["token"]
|
|
||||||
# min_balance = token_requirements.get("min_balance", 0)
|
|
||||||
# balance = await wallet_service.get_balance(user_id, token_type)
|
|
||||||
# if balance < min_balance:
|
|
||||||
# return TokenGateCheckResponse(
|
|
||||||
# allowed=False,
|
|
||||||
# reason=f"Insufficient {token_type} balance",
|
|
||||||
# missing_requirements={"token": token_type, "required": min_balance, "current": balance}
|
|
||||||
# )
|
|
||||||
|
|
||||||
# Заглушка: завжди дозволяємо
|
|
||||||
return TokenGateCheckResponse(allowed=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Health Check ==========
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health():
|
||||||
"""Health check endpoint"""
|
"""Health check"""
|
||||||
return {"status": "ok", "service": "memory-service"}
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": settings.service_name,
|
||||||
|
"vector_store": await vector_store.get_collection_stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ========== User Facts Endpoints ==========
|
# ============================================================================
|
||||||
|
# THREADS (Conversations)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
@app.post("/facts/upsert", response_model=UserFactUpsertResponse)
|
@app.post("/threads", response_model=ThreadResponse)
|
||||||
async def upsert_fact(
|
async def create_thread(request: CreateThreadRequest):
|
||||||
fact_request: UserFactUpsertRequest,
|
"""Create new conversation thread"""
|
||||||
db: Session = Depends(get_db),
|
thread = await db.create_thread(
|
||||||
user_id: str = Depends(verify_token)
|
org_id=request.org_id,
|
||||||
|
user_id=request.user_id,
|
||||||
|
workspace_id=request.workspace_id,
|
||||||
|
agent_id=request.agent_id,
|
||||||
|
title=request.title,
|
||||||
|
tags=request.tags,
|
||||||
|
metadata=request.metadata
|
||||||
|
)
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/threads/{thread_id}", response_model=ThreadResponse)
|
||||||
|
async def get_thread(thread_id: UUID):
|
||||||
|
"""Get thread by ID"""
|
||||||
|
thread = await db.get_thread(thread_id)
|
||||||
|
if not thread:
|
||||||
|
raise HTTPException(status_code=404, detail="Thread not found")
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/threads", response_model=List[ThreadResponse])
|
||||||
|
async def list_threads(
|
||||||
|
user_id: UUID = Query(...),
|
||||||
|
org_id: UUID = Query(...),
|
||||||
|
workspace_id: Optional[UUID] = None,
|
||||||
|
agent_id: Optional[UUID] = None,
|
||||||
|
limit: int = Query(default=20, le=100)
|
||||||
):
|
):
|
||||||
"""
|
"""List threads for user"""
|
||||||
Створити або оновити факт користувача (upsert)
|
threads = await db.list_threads(
|
||||||
|
org_id=org_id,
|
||||||
|
user_id=user_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
return threads
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# EVENTS (Short-term Memory)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.post("/events", response_model=EventResponse)
|
||||||
|
async def add_event(request: AddEventRequest):
|
||||||
|
"""Add event to conversation (message, tool call, etc.)"""
|
||||||
|
event = await db.add_event(
|
||||||
|
thread_id=request.thread_id,
|
||||||
|
event_type=request.event_type,
|
||||||
|
role=request.role,
|
||||||
|
content=request.content,
|
||||||
|
tool_name=request.tool_name,
|
||||||
|
tool_input=request.tool_input,
|
||||||
|
tool_output=request.tool_output,
|
||||||
|
payload=request.payload,
|
||||||
|
token_count=request.token_count,
|
||||||
|
model_used=request.model_used,
|
||||||
|
latency_ms=request.latency_ms,
|
||||||
|
metadata=request.metadata
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/threads/{thread_id}/events", response_model=List[EventResponse])
|
||||||
|
async def get_events(
|
||||||
|
thread_id: UUID,
|
||||||
|
limit: int = Query(default=50, le=200),
|
||||||
|
offset: int = Query(default=0)
|
||||||
|
):
|
||||||
|
"""Get events for thread (most recent first)"""
|
||||||
|
events = await db.get_events(thread_id, limit=limit, offset=offset)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MEMORIES (Long-term Memory)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.post("/memories", response_model=MemoryResponse)
|
||||||
|
async def create_memory(request: CreateMemoryRequest):
|
||||||
|
"""Create long-term memory item"""
|
||||||
|
# Create in PostgreSQL
|
||||||
|
memory = await db.create_memory(
|
||||||
|
org_id=request.org_id,
|
||||||
|
user_id=request.user_id,
|
||||||
|
workspace_id=request.workspace_id,
|
||||||
|
agent_id=request.agent_id,
|
||||||
|
category=request.category,
|
||||||
|
fact_text=request.fact_text,
|
||||||
|
confidence=request.confidence,
|
||||||
|
source_event_id=request.source_event_id,
|
||||||
|
source_thread_id=request.source_thread_id,
|
||||||
|
extraction_method=request.extraction_method,
|
||||||
|
is_sensitive=request.is_sensitive,
|
||||||
|
retention=request.retention,
|
||||||
|
ttl_days=request.ttl_days,
|
||||||
|
tags=request.tags,
|
||||||
|
metadata=request.metadata
|
||||||
|
)
|
||||||
|
|
||||||
Це основний ендпоінт для контрольованої довгострокової пам'яті.
|
# Index in Qdrant
|
||||||
Підтримує токен-гейт інтеграцію.
|
point_id = await vector_store.index_memory(
|
||||||
"""
|
memory_id=memory["memory_id"],
|
||||||
# Перевірка токен-гейту якщо потрібно
|
text=request.fact_text,
|
||||||
if fact_request.token_gated and fact_request.token_requirements:
|
org_id=request.org_id,
|
||||||
gate_check = await check_token_gate(
|
user_id=request.user_id,
|
||||||
fact_request.user_id,
|
category=request.category,
|
||||||
fact_request.token_requirements,
|
agent_id=request.agent_id,
|
||||||
db
|
workspace_id=request.workspace_id,
|
||||||
)
|
thread_id=request.source_thread_id
|
||||||
if not gate_check.allowed:
|
)
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
# Update memory with embedding ID
|
||||||
detail=f"Token gate check failed: {gate_check.reason}"
|
await db.update_memory_embedding_id(memory["memory_id"], point_id)
|
||||||
|
|
||||||
|
return memory
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/memories/{memory_id}", response_model=MemoryResponse)
|
||||||
|
async def get_memory(memory_id: UUID):
|
||||||
|
"""Get memory by ID"""
|
||||||
|
memory = await db.get_memory(memory_id)
|
||||||
|
if not memory:
|
||||||
|
raise HTTPException(status_code=404, detail="Memory not found")
|
||||||
|
return memory
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/memories", response_model=List[MemoryResponse])
|
||||||
|
async def list_memories(
|
||||||
|
user_id: UUID = Query(...),
|
||||||
|
org_id: UUID = Query(...),
|
||||||
|
agent_id: Optional[UUID] = None,
|
||||||
|
workspace_id: Optional[UUID] = None,
|
||||||
|
category: Optional[MemoryCategory] = None,
|
||||||
|
include_global: bool = True,
|
||||||
|
limit: int = Query(default=50, le=200)
|
||||||
|
):
|
||||||
|
"""List memories for user"""
|
||||||
|
memories = await db.list_memories(
|
||||||
|
org_id=org_id,
|
||||||
|
user_id=user_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
category=category,
|
||||||
|
include_global=include_global,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
return memories
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/memories/{memory_id}/feedback")
|
||||||
|
async def memory_feedback(memory_id: UUID, request: MemoryFeedbackRequest):
|
||||||
|
"""User feedback on memory (confirm/reject/edit/delete)"""
|
||||||
|
memory = await db.get_memory(memory_id)
|
||||||
|
if not memory:
|
||||||
|
raise HTTPException(status_code=404, detail="Memory not found")
|
||||||
|
|
||||||
|
# Record feedback
|
||||||
|
await db.add_memory_feedback(
|
||||||
|
memory_id=memory_id,
|
||||||
|
user_id=request.user_id,
|
||||||
|
action=request.action,
|
||||||
|
old_value=memory["fact_text"],
|
||||||
|
new_value=request.new_value,
|
||||||
|
reason=request.reason
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply action
|
||||||
|
if request.action == FeedbackAction.CONFIRM:
|
||||||
|
new_confidence = min(1.0, memory["confidence"] + settings.memory_confirm_boost)
|
||||||
|
await db.update_memory_confidence(memory_id, new_confidence, verified=True)
|
||||||
|
|
||||||
|
elif request.action == FeedbackAction.REJECT:
|
||||||
|
new_confidence = max(0.0, memory["confidence"] - settings.memory_reject_penalty)
|
||||||
|
if new_confidence < settings.memory_min_confidence:
|
||||||
|
# Mark as invalid
|
||||||
|
await db.invalidate_memory(memory_id)
|
||||||
|
await vector_store.delete_memory(memory_id)
|
||||||
|
else:
|
||||||
|
await db.update_memory_confidence(memory_id, new_confidence)
|
||||||
|
|
||||||
|
elif request.action == FeedbackAction.EDIT:
|
||||||
|
if request.new_value:
|
||||||
|
await db.update_memory_text(memory_id, request.new_value)
|
||||||
|
# Re-index with new text
|
||||||
|
await vector_store.delete_memory(memory_id)
|
||||||
|
await vector_store.index_memory(
|
||||||
|
memory_id=memory_id,
|
||||||
|
text=request.new_value,
|
||||||
|
org_id=memory["org_id"],
|
||||||
|
user_id=memory["user_id"],
|
||||||
|
category=memory["category"],
|
||||||
|
agent_id=memory.get("agent_id"),
|
||||||
|
workspace_id=memory.get("workspace_id")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif request.action == FeedbackAction.DELETE:
|
||||||
|
await db.invalidate_memory(memory_id)
|
||||||
|
await vector_store.delete_memory(memory_id)
|
||||||
|
|
||||||
|
return {"status": "ok", "action": request.action.value}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# RETRIEVAL (Semantic Search)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.post("/retrieve", response_model=RetrievalResponse)
|
||||||
|
async def retrieve_memories(request: RetrievalRequest):
|
||||||
|
"""
|
||||||
|
Semantic retrieval of relevant memories.
|
||||||
|
|
||||||
# Перевірка прав доступу (користувач може змінювати тільки свої факти)
|
Performs multiple queries and deduplicates results.
|
||||||
if fact_request.user_id != user_id:
|
"""
|
||||||
raise HTTPException(status_code=403, detail="Cannot modify other user's facts")
|
all_results = []
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
fact, created = upsert_user_fact(db, fact_request)
|
for query in request.queries:
|
||||||
|
results = await vector_store.search_memories(
|
||||||
|
query=query,
|
||||||
|
org_id=request.org_id,
|
||||||
|
user_id=request.user_id,
|
||||||
|
agent_id=request.agent_id,
|
||||||
|
workspace_id=request.workspace_id,
|
||||||
|
categories=request.categories,
|
||||||
|
include_global=request.include_global,
|
||||||
|
top_k=request.top_k
|
||||||
|
)
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
memory_id = r.get("memory_id")
|
||||||
|
if memory_id and memory_id not in seen_ids:
|
||||||
|
seen_ids.add(memory_id)
|
||||||
|
|
||||||
|
# Get full memory from DB for confidence check
|
||||||
|
memory = await db.get_memory(UUID(memory_id))
|
||||||
|
if memory and memory["confidence"] >= request.min_confidence:
|
||||||
|
all_results.append(RetrievalResult(
|
||||||
|
memory_id=UUID(memory_id),
|
||||||
|
fact_text=r["text"],
|
||||||
|
category=MemoryCategory(r["category"]),
|
||||||
|
confidence=memory["confidence"],
|
||||||
|
relevance_score=r["score"],
|
||||||
|
agent_id=UUID(r["agent_id"]) if r.get("agent_id") else None,
|
||||||
|
is_global=r.get("agent_id") is None
|
||||||
|
))
|
||||||
|
|
||||||
|
# Update usage stats
|
||||||
|
await db.increment_memory_usage(UUID(memory_id))
|
||||||
|
|
||||||
return UserFactUpsertResponse(
|
# Sort by relevance
|
||||||
fact=UserFactResponse.model_validate(fact),
|
all_results.sort(key=lambda x: x.relevance_score, reverse=True)
|
||||||
created=created
|
|
||||||
|
return RetrievalResponse(
|
||||||
|
results=all_results[:request.top_k],
|
||||||
|
query_count=len(request.queries),
|
||||||
|
total_results=len(all_results)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/facts", response_model=List[UserFactResponse])
|
# ============================================================================
|
||||||
async def list_facts(
|
# SUMMARIES (Mid-term Memory)
|
||||||
team_id: Optional[str] = Query(None),
|
# ============================================================================
|
||||||
fact_keys: Optional[str] = Query(None, description="Comma-separated list of fact keys"),
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""Отримати список фактів користувача"""
|
|
||||||
fact_keys_list = None
|
|
||||||
if fact_keys:
|
|
||||||
fact_keys_list = [k.strip() for k in fact_keys.split(",")]
|
|
||||||
|
|
||||||
facts = get_user_facts(db, user_id, team_id, fact_keys_list, skip, limit)
|
|
||||||
return [UserFactResponse.model_validate(f) for f in facts]
|
|
||||||
|
|
||||||
|
@app.post("/threads/{thread_id}/summarize", response_model=SummaryResponse)
|
||||||
@app.get("/facts/{fact_key}", response_model=UserFactResponse)
|
async def create_summary(thread_id: UUID, request: SummaryRequest):
|
||||||
async def get_fact(
|
|
||||||
fact_key: str,
|
|
||||||
team_id: Optional[str] = Query(None),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""Отримати конкретний факт за ключем"""
|
|
||||||
fact = get_user_fact(db, user_id, fact_key, team_id)
|
|
||||||
if not fact:
|
|
||||||
raise HTTPException(status_code=404, detail="Fact not found")
|
|
||||||
return UserFactResponse.model_validate(fact)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/facts", response_model=UserFactResponse)
|
|
||||||
async def create_fact(
|
|
||||||
fact: UserFactCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""Створити новий факт"""
|
|
||||||
if fact.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=403, detail="Cannot create fact for other user")
|
|
||||||
|
|
||||||
db_fact = create_user_fact(db, fact)
|
|
||||||
return UserFactResponse.model_validate(db_fact)
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/facts/{fact_id}", response_model=UserFactResponse)
|
|
||||||
async def update_fact(
|
|
||||||
fact_id: str,
|
|
||||||
fact_update: UserFactUpdate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""Оновити факт"""
|
|
||||||
fact = db.query(UserFact).filter(UserFact.id == fact_id).first()
|
|
||||||
if not fact:
|
|
||||||
raise HTTPException(status_code=404, detail="Fact not found")
|
|
||||||
|
|
||||||
if fact.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=403, detail="Cannot modify other user's fact")
|
|
||||||
|
|
||||||
updated_fact = update_user_fact(db, fact_id, fact_update)
|
|
||||||
if not updated_fact:
|
|
||||||
raise HTTPException(status_code=404, detail="Fact not found")
|
|
||||||
|
|
||||||
return UserFactResponse.model_validate(updated_fact)
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/facts/{fact_id}")
|
|
||||||
async def delete_fact(
|
|
||||||
fact_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""Видалити факт"""
|
|
||||||
fact = db.query(UserFact).filter(UserFact.id == fact_id).first()
|
|
||||||
if not fact:
|
|
||||||
raise HTTPException(status_code=404, detail="Fact not found")
|
|
||||||
|
|
||||||
if fact.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=403, detail="Cannot delete other user's fact")
|
|
||||||
|
|
||||||
success = delete_user_fact(db, fact_id)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=404, detail="Fact not found")
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/facts/token-gated", response_model=List[UserFactResponse])
|
|
||||||
async def list_token_gated_facts(
|
|
||||||
team_id: Optional[str] = Query(None),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""Отримати токен-гейт факти користувача"""
|
|
||||||
facts = get_user_facts_by_token_gate(db, user_id, team_id)
|
|
||||||
return [UserFactResponse.model_validate(f) for f in facts]
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Dialog Summary Endpoints ==========
|
|
||||||
|
|
||||||
@app.post("/summaries", response_model=DialogSummaryResponse)
|
|
||||||
async def create_summary(
|
|
||||||
summary: DialogSummaryCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Створити підсумок діалогу
|
Generate rolling summary for thread.
|
||||||
|
|
||||||
Використовується для масштабування без переповнення контексту.
|
Compresses old events into a structured summary.
|
||||||
Агрегує інформацію про сесії/діалоги.
|
|
||||||
"""
|
"""
|
||||||
db_summary = create_dialog_summary(db, summary)
|
thread = await db.get_thread(thread_id)
|
||||||
return DialogSummaryResponse.model_validate(db_summary)
|
if not thread:
|
||||||
|
raise HTTPException(status_code=404, detail="Thread not found")
|
||||||
|
|
||||||
@app.get("/summaries", response_model=DialogSummaryListResponse)
|
# Check if summary is needed
|
||||||
async def list_summaries(
|
if not request.force and thread["total_tokens"] < settings.summary_trigger_tokens:
|
||||||
team_id: Optional[str] = Query(None),
|
raise HTTPException(
|
||||||
channel_id: Optional[str] = Query(None),
|
status_code=400,
|
||||||
agent_id: Optional[str] = Query(None),
|
detail=f"Token count ({thread['total_tokens']}) below threshold ({settings.summary_trigger_tokens})"
|
||||||
user_id_param: Optional[str] = Query(None, alias="user_id"),
|
)
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(50, ge=1, le=200),
|
# Get events to summarize
|
||||||
cursor: Optional[str] = Query(None),
|
events = await db.get_events_for_summary(thread_id)
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
# TODO: Call LLM to generate summary
|
||||||
):
|
# For now, create a placeholder
|
||||||
"""Отримати список підсумків діалогів"""
|
summary_text = f"Summary of {len(events)} events. [Implement LLM summarization]"
|
||||||
summaries, next_cursor = get_dialog_summaries(
|
state = {
|
||||||
db, team_id, channel_id, agent_id, user_id_param, skip, limit, cursor
|
"goals": [],
|
||||||
|
"decisions": [],
|
||||||
|
"open_questions": [],
|
||||||
|
"next_steps": [],
|
||||||
|
"key_facts": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create summary
|
||||||
|
summary = await db.create_summary(
|
||||||
|
thread_id=thread_id,
|
||||||
|
summary_text=summary_text,
|
||||||
|
state=state,
|
||||||
|
events_from_seq=events[0]["sequence_num"] if events else 0,
|
||||||
|
events_to_seq=events[-1]["sequence_num"] if events else 0,
|
||||||
|
events_count=len(events)
|
||||||
)
|
)
|
||||||
|
|
||||||
return DialogSummaryListResponse(
|
# Index summary in Qdrant
|
||||||
items=[DialogSummaryResponse.model_validate(s) for s in summaries],
|
await vector_store.index_summary(
|
||||||
total=len(summaries),
|
summary_id=summary["summary_id"],
|
||||||
cursor=next_cursor
|
text=summary_text,
|
||||||
|
thread_id=thread_id,
|
||||||
|
org_id=thread["org_id"],
|
||||||
|
user_id=thread["user_id"],
|
||||||
|
agent_id=thread.get("agent_id"),
|
||||||
|
workspace_id=thread.get("workspace_id")
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/threads/{thread_id}/summary", response_model=Optional[SummaryResponse])
|
||||||
|
async def get_latest_summary(thread_id: UUID):
|
||||||
|
"""Get latest summary for thread"""
|
||||||
|
summary = await db.get_latest_summary(thread_id)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CONTEXT (Full context for agent)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.get("/threads/{thread_id}/context", response_model=ContextResponse)
|
||||||
|
async def get_context(
|
||||||
|
thread_id: UUID,
|
||||||
|
queries: List[str] = Query(default=[]),
|
||||||
|
top_k: int = Query(default=10)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get full context for agent prompt.
|
||||||
|
|
||||||
|
Combines:
|
||||||
|
- Latest summary (mid-term)
|
||||||
|
- Recent messages (short-term)
|
||||||
|
- Retrieved memories (long-term)
|
||||||
|
"""
|
||||||
|
thread = await db.get_thread(thread_id)
|
||||||
|
if not thread:
|
||||||
|
raise HTTPException(status_code=404, detail="Thread not found")
|
||||||
|
|
||||||
|
# Get summary
|
||||||
|
summary = await db.get_latest_summary(thread_id)
|
||||||
|
|
||||||
|
# Get recent messages
|
||||||
|
recent = await db.get_events(
|
||||||
|
thread_id,
|
||||||
|
limit=settings.short_term_window_messages
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve memories if queries provided
|
||||||
|
retrieved = []
|
||||||
|
if queries:
|
||||||
|
retrieval_response = await retrieve_memories(RetrievalRequest(
|
||||||
|
org_id=thread["org_id"],
|
||||||
|
user_id=thread["user_id"],
|
||||||
|
agent_id=thread.get("agent_id"),
|
||||||
|
workspace_id=thread.get("workspace_id"),
|
||||||
|
queries=queries,
|
||||||
|
top_k=top_k,
|
||||||
|
include_global=True
|
||||||
|
))
|
||||||
|
retrieved = retrieval_response.results
|
||||||
|
|
||||||
|
# Estimate tokens
|
||||||
|
token_estimate = sum(e.get("token_count", 0) or 0 for e in recent)
|
||||||
|
if summary:
|
||||||
|
token_estimate += summary.get("summary_tokens", 0) or 0
|
||||||
|
|
||||||
|
return ContextResponse(
|
||||||
|
thread_id=thread_id,
|
||||||
|
summary=summary,
|
||||||
|
recent_messages=recent,
|
||||||
|
retrieved_memories=retrieved,
|
||||||
|
token_estimate=token_estimate
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/summaries/{summary_id}", response_model=DialogSummaryResponse)
|
# ============================================================================
|
||||||
async def get_summary(
|
# ADMIN
|
||||||
summary_id: str,
|
# ============================================================================
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""Отримати підсумок за ID"""
|
|
||||||
summary = get_dialog_summary(db, summary_id)
|
|
||||||
if not summary:
|
|
||||||
raise HTTPException(status_code=404, detail="Summary not found")
|
|
||||||
return DialogSummaryResponse.model_validate(summary)
|
|
||||||
|
|
||||||
|
@app.get("/stats")
|
||||||
@app.delete("/summaries/{summary_id}")
|
async def get_stats():
|
||||||
async def delete_summary(
|
"""Get service statistics"""
|
||||||
summary_id: str,
|
return {
|
||||||
db: Session = Depends(get_db),
|
"vector_store": await vector_store.get_collection_stats(),
|
||||||
user_id: str = Depends(verify_token)
|
"database": await db.get_stats()
|
||||||
):
|
}
|
||||||
"""Видалити підсумок"""
|
|
||||||
success = delete_dialog_summary(db, summary_id)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=404, detail="Summary not found")
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Agent Memory Event Endpoints ==========
|
|
||||||
|
|
||||||
@app.post("/agents/{agent_id}/memory", response_model=AgentMemoryEventResponse)
|
|
||||||
async def create_memory_event(
|
|
||||||
agent_id: str,
|
|
||||||
event: AgentMemoryEventCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""Створити подію пам'яті агента"""
|
|
||||||
# Перевірка що agent_id збігається
|
|
||||||
if event.agent_id != agent_id:
|
|
||||||
raise HTTPException(status_code=400, detail="agent_id mismatch")
|
|
||||||
|
|
||||||
db_event = create_agent_memory_event(db, event)
|
|
||||||
return AgentMemoryEventResponse.model_validate(db_event)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/agents/{agent_id}/memory", response_model=AgentMemoryEventListResponse)
|
|
||||||
async def list_memory_events(
|
|
||||||
agent_id: str,
|
|
||||||
team_id: Optional[str] = Query(None),
|
|
||||||
channel_id: Optional[str] = Query(None),
|
|
||||||
scope: Optional[str] = Query(None, description="short_term | mid_term | long_term"),
|
|
||||||
kind: Optional[str] = Query(None, description="message | fact | summary | note"),
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(50, ge=1, le=200),
|
|
||||||
cursor: Optional[str] = Query(None),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""Отримати список подій пам'яті агента"""
|
|
||||||
events, next_cursor = get_agent_memory_events(
|
|
||||||
db, agent_id, team_id, channel_id, scope, kind, skip, limit, cursor
|
|
||||||
)
|
|
||||||
|
|
||||||
return AgentMemoryEventListResponse(
|
|
||||||
items=[AgentMemoryEventResponse.model_validate(e) for e in events],
|
|
||||||
total=len(events),
|
|
||||||
cursor=next_cursor
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/agents/{agent_id}/memory/{event_id}")
|
|
||||||
async def delete_memory_event(
|
|
||||||
agent_id: str,
|
|
||||||
event_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""Видалити подію пам'яті"""
|
|
||||||
success = delete_agent_memory_event(db, event_id)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=404, detail="Memory event not found")
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Monitor Events Endpoints (Batch Processing) ==========
|
|
||||||
|
|
||||||
from app.monitor_events import MonitorEventBatch, MonitorEventResponse, save_monitor_events_batch, save_monitor_event_single
|
|
||||||
|
|
||||||
@app.post("/api/memory/monitor-events/batch", response_model=MonitorEventResponse)
|
|
||||||
async def save_monitor_events_batch_endpoint(
|
|
||||||
batch: MonitorEventBatch,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
authorization: Optional[str] = Header(None)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Зберегти батч подій Monitor Agent
|
|
||||||
Оптимізовано для збору метрик з багатьох нод
|
|
||||||
"""
|
|
||||||
return await save_monitor_events_batch(batch, db, authorization)
|
|
||||||
|
|
||||||
@app.post("/api/memory/monitor-events/{node_id}", response_model=AgentMemoryEventResponse)
|
|
||||||
async def save_monitor_event_endpoint(
|
|
||||||
node_id: str,
|
|
||||||
event: Dict[str, Any],
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
authorization: Optional[str] = Header(None)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Зберегти одну подію Monitor Agent
|
|
||||||
"""
|
|
||||||
return await save_monitor_event_single(node_id, event, db, authorization)
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Token Gate Integration Endpoint ==========
|
|
||||||
|
|
||||||
@app.post("/token-gate/check", response_model=TokenGateCheckResponse)
|
|
||||||
async def check_token_gate_endpoint(
|
|
||||||
check: TokenGateCheck,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: str = Depends(verify_token)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Перевірка токен-гейту для факту
|
|
||||||
|
|
||||||
Інтеграція з RBAC/Wallet Service для перевірки доступу
|
|
||||||
"""
|
|
||||||
if check.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=403, detail="Cannot check token gate for other user")
|
|
||||||
|
|
||||||
return await check_token_gate(user_id, check.token_requirements, db)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
|
|
||||||
|
|||||||
@@ -1,178 +1,249 @@
|
|||||||
"""
|
"""
|
||||||
SQLAlchemy моделі для Memory Service
|
DAARION Memory Service - Pydantic Models
|
||||||
Підтримує: user_facts, dialog_summaries, agent_memory_events
|
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime
|
||||||
from sqlalchemy import (
|
from typing import Optional, List, Any
|
||||||
Column, String, Text, JSON, TIMESTAMP,
|
from uuid import UUID
|
||||||
CheckConstraint, Index, Boolean, Integer
|
from enum import Enum
|
||||||
)
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Перевірка типу бази даних
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./memory.db")
|
|
||||||
IS_SQLITE = "sqlite" in DATABASE_URL.lower()
|
|
||||||
|
|
||||||
if IS_SQLITE:
|
|
||||||
# Для SQLite використовуємо стандартні типи
|
|
||||||
from sqlalchemy import JSON as JSONB_TYPE
|
|
||||||
UUID_TYPE = String # SQLite не має UUID, використовуємо String
|
|
||||||
else:
|
|
||||||
# Для PostgreSQL використовуємо специфічні типи
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
UUID_TYPE = UUID
|
|
||||||
JSONB_TYPE = JSONB
|
|
||||||
|
|
||||||
try:
|
|
||||||
from pgvector.sqlalchemy import Vector
|
|
||||||
HAS_PGVECTOR = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_PGVECTOR = False
|
|
||||||
# Заглушка для SQLite
|
|
||||||
class Vector:
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
|
|
||||||
class UserFact(Base):
|
# ============================================================================
|
||||||
"""
|
# ENUMS
|
||||||
Довгострокові факти про користувача
|
# ============================================================================
|
||||||
Використовується для контрольованої довгострокової пам'яті
|
|
||||||
(мови, вподобання, тип користувача, токен-статуси)
|
|
||||||
"""
|
|
||||||
__tablename__ = "user_facts"
|
|
||||||
|
|
||||||
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
|
class EventType(str, Enum):
|
||||||
user_id = Column(String, nullable=False, index=True) # Без FK constraint для тестування
|
MESSAGE = "message"
|
||||||
team_id = Column(String, nullable=True, index=True) # Без FK constraint, оскільки teams може не існувати
|
TOOL_CALL = "tool_call"
|
||||||
|
TOOL_RESULT = "tool_result"
|
||||||
# Ключ факту (наприклад: "language", "is_donor", "is_validator", "top_contributor")
|
DECISION = "decision"
|
||||||
fact_key = Column(String, nullable=False, index=True)
|
SUMMARY = "summary"
|
||||||
|
MEMORY_WRITE = "memory_write"
|
||||||
# Значення факту (може бути текст, число, boolean, JSON)
|
MEMORY_RETRACT = "memory_retract"
|
||||||
fact_value = Column(Text, nullable=True)
|
ERROR = "error"
|
||||||
fact_value_json = Column(JSONB_TYPE, nullable=True)
|
|
||||||
|
|
||||||
# Метадані: джерело, впевненість, термін дії
|
|
||||||
meta = Column(JSONB_TYPE, nullable=False, server_default="{}")
|
|
||||||
|
|
||||||
# Токен-гейт: чи залежить факт від токенів/активності
|
|
||||||
token_gated = Column(Boolean, nullable=False, server_default="false")
|
|
||||||
token_requirements = Column(JSONB_TYPE, nullable=True) # {"token": "DAAR", "min_balance": 1}
|
|
||||||
|
|
||||||
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
|
|
||||||
updated_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=True, onupdate=func.now())
|
|
||||||
expires_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=True) # Для тимчасових фактів
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_user_facts_user_key", "user_id", "fact_key"),
|
|
||||||
Index("idx_user_facts_team", "team_id"),
|
|
||||||
Index("idx_user_facts_token_gated", "token_gated"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DialogSummary(Base):
|
class MessageRole(str, Enum):
|
||||||
"""
|
USER = "user"
|
||||||
Підсумки діалогів для масштабування без переповнення контексту
|
ASSISTANT = "assistant"
|
||||||
Зберігає агреговану інформацію про сесії/діалоги
|
SYSTEM = "system"
|
||||||
"""
|
TOOL = "tool"
|
||||||
__tablename__ = "dialog_summaries"
|
|
||||||
|
|
||||||
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
|
|
||||||
|
|
||||||
# Контекст діалогу (без FK constraints для тестування)
|
|
||||||
team_id = Column(String, nullable=False, index=True)
|
|
||||||
channel_id = Column(String, nullable=True, index=True)
|
|
||||||
agent_id = Column(String, nullable=True, index=True)
|
|
||||||
user_id = Column(String, nullable=True, index=True)
|
|
||||||
|
|
||||||
# Період, який охоплює підсумок
|
|
||||||
period_start = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False)
|
|
||||||
period_end = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False)
|
|
||||||
|
|
||||||
# Підсумок
|
|
||||||
summary_text = Column(Text, nullable=False)
|
|
||||||
summary_json = Column(JSONB_TYPE, nullable=True) # Структуровані дані
|
|
||||||
|
|
||||||
# Статистика
|
|
||||||
message_count = Column(Integer, nullable=False, server_default="0")
|
|
||||||
participant_count = Column(Integer, nullable=False, server_default="0")
|
|
||||||
|
|
||||||
# Ключові теми/теги
|
|
||||||
topics = Column(JSONB_TYPE, nullable=True) # ["project-planning", "bug-fix", ...]
|
|
||||||
|
|
||||||
# Метадані
|
|
||||||
meta = Column(JSONB_TYPE, nullable=False, server_default="{}")
|
|
||||||
|
|
||||||
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_dialog_summaries_team_period", "team_id", "period_start", "period_end"),
|
|
||||||
Index("idx_dialog_summaries_channel", "channel_id"),
|
|
||||||
Index("idx_dialog_summaries_agent", "agent_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentMemoryEvent(Base):
|
class MemoryCategory(str, Enum):
|
||||||
"""
|
PREFERENCE = "preference"
|
||||||
Події пам'яті агентів (short-term, mid-term, long-term)
|
IDENTITY = "identity"
|
||||||
Базується на документації: docs/cursor/13_agent_memory_system.md
|
CONSTRAINT = "constraint"
|
||||||
"""
|
PROJECT_FACT = "project_fact"
|
||||||
__tablename__ = "agent_memory_events"
|
RELATIONSHIP = "relationship"
|
||||||
|
SKILL = "skill"
|
||||||
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
|
GOAL = "goal"
|
||||||
|
CONTEXT = "context"
|
||||||
# Без FK constraints для тестування
|
FEEDBACK = "feedback"
|
||||||
agent_id = Column(String, nullable=False, index=True)
|
|
||||||
team_id = Column(String, nullable=False, index=True)
|
|
||||||
channel_id = Column(String, nullable=True, index=True)
|
|
||||||
user_id = Column(String, nullable=True, index=True)
|
|
||||||
|
|
||||||
# Scope: short_term, mid_term, long_term
|
|
||||||
scope = Column(String, nullable=False)
|
|
||||||
|
|
||||||
# Kind: message, fact, summary, note
|
|
||||||
kind = Column(String, nullable=False)
|
|
||||||
|
|
||||||
# Тіло події
|
|
||||||
body_text = Column(Text, nullable=True)
|
|
||||||
body_json = Column(JSONB_TYPE, nullable=True)
|
|
||||||
|
|
||||||
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
CheckConstraint("scope IN ('short_term', 'mid_term', 'long_term')", name="ck_agent_memory_scope"),
|
|
||||||
CheckConstraint("kind IN ('message', 'fact', 'summary', 'note')", name="ck_agent_memory_kind"),
|
|
||||||
Index("idx_agent_memory_events_agent_team_scope", "agent_id", "team_id", "scope"),
|
|
||||||
Index("idx_agent_memory_events_channel", "agent_id", "channel_id"),
|
|
||||||
Index("idx_agent_memory_events_created_at", "created_at"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentMemoryFactsVector(Base):
|
class RetentionPolicy(str, Enum):
|
||||||
"""
|
PERMANENT = "permanent"
|
||||||
Векторні представлення фактів для RAG (Retrieval-Augmented Generation)
|
SESSION = "session"
|
||||||
"""
|
TTL_DAYS = "ttl_days"
|
||||||
__tablename__ = "agent_memory_facts_vector"
|
UNTIL_REVOKED = "until_revoked"
|
||||||
|
|
||||||
id = Column(UUID_TYPE(as_uuid=False) if not IS_SQLITE else String, primary_key=True, server_default=func.gen_random_uuid() if not IS_SQLITE else None)
|
|
||||||
|
|
||||||
# Без FK constraints для тестування
|
|
||||||
agent_id = Column(String, nullable=False, index=True)
|
|
||||||
team_id = Column(String, nullable=False, index=True)
|
|
||||||
|
|
||||||
fact_text = Column(Text, nullable=False)
|
|
||||||
embedding = Column(Vector(1536), nullable=True) if HAS_PGVECTOR else Column(Text, nullable=True) # OpenAI ada-002 embedding size
|
|
||||||
|
|
||||||
meta = Column(JSONB_TYPE, nullable=False, server_default="{}")
|
|
||||||
|
|
||||||
created_at = Column(TIMESTAMP(timezone=True) if not IS_SQLITE else TIMESTAMP, nullable=False, server_default=func.now())
|
|
||||||
|
|
||||||
__table_args__ = (
|
class FeedbackAction(str, Enum):
|
||||||
Index("idx_agent_memory_facts_vector_agent_team", "agent_id", "team_id"),
|
CONFIRM = "confirm"
|
||||||
)
|
REJECT = "reject"
|
||||||
|
EDIT = "edit"
|
||||||
|
DELETE = "delete"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# REQUEST MODELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class CreateThreadRequest(BaseModel):
|
||||||
|
"""Create new conversation thread"""
|
||||||
|
org_id: UUID
|
||||||
|
workspace_id: Optional[UUID] = None
|
||||||
|
user_id: UUID
|
||||||
|
agent_id: Optional[UUID] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
tags: List[str] = []
|
||||||
|
metadata: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class AddEventRequest(BaseModel):
|
||||||
|
"""Add event to conversation"""
|
||||||
|
thread_id: UUID
|
||||||
|
event_type: EventType
|
||||||
|
role: Optional[MessageRole] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
tool_name: Optional[str] = None
|
||||||
|
tool_input: Optional[dict] = None
|
||||||
|
tool_output: Optional[dict] = None
|
||||||
|
payload: dict = {}
|
||||||
|
token_count: Optional[int] = None
|
||||||
|
model_used: Optional[str] = None
|
||||||
|
latency_ms: Optional[int] = None
|
||||||
|
metadata: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class CreateMemoryRequest(BaseModel):
|
||||||
|
"""Create long-term memory item"""
|
||||||
|
org_id: UUID
|
||||||
|
workspace_id: Optional[UUID] = None
|
||||||
|
user_id: UUID
|
||||||
|
agent_id: Optional[UUID] = None # null = global
|
||||||
|
category: MemoryCategory
|
||||||
|
fact_text: str
|
||||||
|
confidence: float = Field(default=0.8, ge=0, le=1)
|
||||||
|
source_event_id: Optional[UUID] = None
|
||||||
|
source_thread_id: Optional[UUID] = None
|
||||||
|
extraction_method: str = "explicit"
|
||||||
|
is_sensitive: bool = False
|
||||||
|
retention: RetentionPolicy = RetentionPolicy.UNTIL_REVOKED
|
||||||
|
ttl_days: Optional[int] = None
|
||||||
|
tags: List[str] = []
|
||||||
|
metadata: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryFeedbackRequest(BaseModel):
|
||||||
|
"""User feedback on memory"""
|
||||||
|
memory_id: UUID
|
||||||
|
user_id: UUID
|
||||||
|
action: FeedbackAction
|
||||||
|
new_value: Optional[str] = None
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RetrievalRequest(BaseModel):
|
||||||
|
"""Semantic retrieval request"""
|
||||||
|
org_id: UUID
|
||||||
|
user_id: UUID
|
||||||
|
agent_id: Optional[UUID] = None
|
||||||
|
workspace_id: Optional[UUID] = None
|
||||||
|
queries: List[str]
|
||||||
|
top_k: int = 10
|
||||||
|
min_confidence: float = 0.5
|
||||||
|
include_global: bool = True
|
||||||
|
categories: Optional[List[MemoryCategory]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryRequest(BaseModel):
|
||||||
|
"""Generate summary for thread"""
|
||||||
|
thread_id: UUID
|
||||||
|
force: bool = False # force even if under token threshold
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# RESPONSE MODELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class ThreadResponse(BaseModel):
|
||||||
|
thread_id: UUID
|
||||||
|
org_id: UUID
|
||||||
|
workspace_id: Optional[UUID]
|
||||||
|
user_id: UUID
|
||||||
|
agent_id: Optional[UUID]
|
||||||
|
title: Optional[str]
|
||||||
|
status: str
|
||||||
|
message_count: int
|
||||||
|
total_tokens: int
|
||||||
|
created_at: datetime
|
||||||
|
last_activity_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EventResponse(BaseModel):
|
||||||
|
event_id: UUID
|
||||||
|
thread_id: UUID
|
||||||
|
event_type: EventType
|
||||||
|
role: Optional[MessageRole]
|
||||||
|
content: Optional[str]
|
||||||
|
tool_name: Optional[str]
|
||||||
|
tool_input: Optional[dict]
|
||||||
|
tool_output: Optional[dict]
|
||||||
|
payload: dict
|
||||||
|
token_count: Optional[int]
|
||||||
|
sequence_num: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryResponse(BaseModel):
|
||||||
|
memory_id: UUID
|
||||||
|
org_id: UUID
|
||||||
|
workspace_id: Optional[UUID]
|
||||||
|
user_id: UUID
|
||||||
|
agent_id: Optional[UUID]
|
||||||
|
category: MemoryCategory
|
||||||
|
fact_text: str
|
||||||
|
confidence: float
|
||||||
|
is_verified: bool
|
||||||
|
is_sensitive: bool
|
||||||
|
retention: RetentionPolicy
|
||||||
|
valid_from: datetime
|
||||||
|
valid_to: Optional[datetime]
|
||||||
|
last_used_at: Optional[datetime]
|
||||||
|
use_count: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryResponse(BaseModel):
|
||||||
|
summary_id: UUID
|
||||||
|
thread_id: UUID
|
||||||
|
version: int
|
||||||
|
summary_text: str
|
||||||
|
state: dict
|
||||||
|
events_count: int
|
||||||
|
compression_ratio: Optional[float]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RetrievalResult(BaseModel):
|
||||||
|
"""Single retrieval result"""
|
||||||
|
memory_id: UUID
|
||||||
|
fact_text: str
|
||||||
|
category: MemoryCategory
|
||||||
|
confidence: float
|
||||||
|
relevance_score: float
|
||||||
|
agent_id: Optional[UUID]
|
||||||
|
is_global: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RetrievalResponse(BaseModel):
|
||||||
|
"""Retrieval response with results"""
|
||||||
|
results: List[RetrievalResult]
|
||||||
|
query_count: int
|
||||||
|
total_results: int
|
||||||
|
|
||||||
|
|
||||||
|
class ContextResponse(BaseModel):
|
||||||
|
"""Full context for agent prompt"""
|
||||||
|
thread_id: UUID
|
||||||
|
summary: Optional[SummaryResponse]
|
||||||
|
recent_messages: List[EventResponse]
|
||||||
|
retrieved_memories: List[RetrievalResult]
|
||||||
|
token_estimate: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# INTERNAL MODELS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class EmbeddingRequest(BaseModel):
|
||||||
|
"""Internal embedding request"""
|
||||||
|
texts: List[str]
|
||||||
|
input_type: str = "search_document" # or "search_query"
|
||||||
|
|
||||||
|
|
||||||
|
class QdrantPayload(BaseModel):
|
||||||
|
"""Qdrant point payload"""
|
||||||
|
org_id: str
|
||||||
|
workspace_id: Optional[str]
|
||||||
|
user_id: str
|
||||||
|
agent_id: Optional[str]
|
||||||
|
thread_id: Optional[str]
|
||||||
|
memory_id: Optional[str]
|
||||||
|
event_id: Optional[str]
|
||||||
|
type: str # "memory", "summary", "message"
|
||||||
|
category: Optional[str]
|
||||||
|
text: str
|
||||||
|
created_at: str
|
||||||
|
|||||||
325
services/memory-service/app/vector_store.py
Normal file
325
services/memory-service/app/vector_store.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"""
|
||||||
|
DAARION Memory Service - Qdrant Vector Store
|
||||||
|
"""
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
import structlog
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models as qmodels
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from .embedding import get_query_embedding, get_document_embeddings
|
||||||
|
from .models import MemoryCategory, QdrantPayload
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class VectorStore:
|
||||||
|
"""Qdrant vector store for semantic search"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = QdrantClient(
|
||||||
|
host=settings.qdrant_host,
|
||||||
|
port=settings.qdrant_port
|
||||||
|
)
|
||||||
|
self.memories_collection = settings.qdrant_collection_memories
|
||||||
|
self.messages_collection = settings.qdrant_collection_messages
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""Initialize collections if they don't exist"""
|
||||||
|
await self._ensure_collection(
|
||||||
|
self.memories_collection,
|
||||||
|
settings.embedding_dimensions
|
||||||
|
)
|
||||||
|
await self._ensure_collection(
|
||||||
|
self.messages_collection,
|
||||||
|
settings.embedding_dimensions
|
||||||
|
)
|
||||||
|
logger.info("vector_store_initialized")
|
||||||
|
|
||||||
|
async def _ensure_collection(self, name: str, dimensions: int):
|
||||||
|
"""Create collection if it doesn't exist"""
|
||||||
|
collections = self.client.get_collections().collections
|
||||||
|
exists = any(c.name == name for c in collections)
|
||||||
|
|
||||||
|
if not exists:
|
||||||
|
self.client.create_collection(
|
||||||
|
collection_name=name,
|
||||||
|
vectors_config=qmodels.VectorParams(
|
||||||
|
size=dimensions,
|
||||||
|
distance=qmodels.Distance.COSINE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create payload indexes for filtering
|
||||||
|
self.client.create_payload_index(
|
||||||
|
collection_name=name,
|
||||||
|
field_name="org_id",
|
||||||
|
field_schema=qmodels.PayloadSchemaType.KEYWORD
|
||||||
|
)
|
||||||
|
self.client.create_payload_index(
|
||||||
|
collection_name=name,
|
||||||
|
field_name="user_id",
|
||||||
|
field_schema=qmodels.PayloadSchemaType.KEYWORD
|
||||||
|
)
|
||||||
|
self.client.create_payload_index(
|
||||||
|
collection_name=name,
|
||||||
|
field_name="agent_id",
|
||||||
|
field_schema=qmodels.PayloadSchemaType.KEYWORD
|
||||||
|
)
|
||||||
|
self.client.create_payload_index(
|
||||||
|
collection_name=name,
|
||||||
|
field_name="type",
|
||||||
|
field_schema=qmodels.PayloadSchemaType.KEYWORD
|
||||||
|
)
|
||||||
|
self.client.create_payload_index(
|
||||||
|
collection_name=name,
|
||||||
|
field_name="category",
|
||||||
|
field_schema=qmodels.PayloadSchemaType.KEYWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("collection_created", name=name, dimensions=dimensions)
|
||||||
|
|
||||||
|
async def index_memory(
|
||||||
|
self,
|
||||||
|
memory_id: UUID,
|
||||||
|
text: str,
|
||||||
|
org_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
category: MemoryCategory,
|
||||||
|
agent_id: Optional[UUID] = None,
|
||||||
|
workspace_id: Optional[UUID] = None,
|
||||||
|
thread_id: Optional[UUID] = None,
|
||||||
|
metadata: Dict[str, Any] = {}
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Index a memory item in Qdrant.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Qdrant point ID
|
||||||
|
"""
|
||||||
|
# Get embedding
|
||||||
|
embeddings = await get_document_embeddings([text])
|
||||||
|
if not embeddings:
|
||||||
|
raise ValueError("Failed to generate embedding")
|
||||||
|
|
||||||
|
vector = embeddings[0]
|
||||||
|
point_id = str(uuid4())
|
||||||
|
|
||||||
|
# Build payload
|
||||||
|
payload = {
|
||||||
|
"org_id": str(org_id),
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"memory_id": str(memory_id),
|
||||||
|
"type": "memory",
|
||||||
|
"category": category.value,
|
||||||
|
"text": text,
|
||||||
|
**metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent_id:
|
||||||
|
payload["agent_id"] = str(agent_id)
|
||||||
|
if workspace_id:
|
||||||
|
payload["workspace_id"] = str(workspace_id)
|
||||||
|
if thread_id:
|
||||||
|
payload["thread_id"] = str(thread_id)
|
||||||
|
|
||||||
|
# Upsert point
|
||||||
|
self.client.upsert(
|
||||||
|
collection_name=self.memories_collection,
|
||||||
|
points=[
|
||||||
|
qmodels.PointStruct(
|
||||||
|
id=point_id,
|
||||||
|
vector=vector,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_indexed",
|
||||||
|
memory_id=str(memory_id),
|
||||||
|
point_id=point_id,
|
||||||
|
category=category.value
|
||||||
|
)
|
||||||
|
|
||||||
|
return point_id
|
||||||
|
|
||||||
|
async def index_summary(
|
||||||
|
self,
|
||||||
|
summary_id: UUID,
|
||||||
|
text: str,
|
||||||
|
thread_id: UUID,
|
||||||
|
org_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
agent_id: Optional[UUID] = None,
|
||||||
|
workspace_id: Optional[UUID] = None
|
||||||
|
) -> str:
|
||||||
|
"""Index a thread summary"""
|
||||||
|
embeddings = await get_document_embeddings([text])
|
||||||
|
if not embeddings:
|
||||||
|
raise ValueError("Failed to generate embedding")
|
||||||
|
|
||||||
|
vector = embeddings[0]
|
||||||
|
point_id = str(uuid4())
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"org_id": str(org_id),
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"thread_id": str(thread_id),
|
||||||
|
"summary_id": str(summary_id),
|
||||||
|
"type": "summary",
|
||||||
|
"text": text
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent_id:
|
||||||
|
payload["agent_id"] = str(agent_id)
|
||||||
|
if workspace_id:
|
||||||
|
payload["workspace_id"] = str(workspace_id)
|
||||||
|
|
||||||
|
self.client.upsert(
|
||||||
|
collection_name=self.memories_collection,
|
||||||
|
points=[
|
||||||
|
qmodels.PointStruct(
|
||||||
|
id=point_id,
|
||||||
|
vector=vector,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("summary_indexed", summary_id=str(summary_id), point_id=point_id)
|
||||||
|
return point_id
|
||||||
|
|
||||||
|
async def search_memories(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
org_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
agent_id: Optional[UUID] = None,
|
||||||
|
workspace_id: Optional[UUID] = None,
|
||||||
|
categories: Optional[List[MemoryCategory]] = None,
|
||||||
|
include_global: bool = True,
|
||||||
|
top_k: int = 10
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Semantic search for memories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query
|
||||||
|
org_id: Organization filter
|
||||||
|
user_id: User filter
|
||||||
|
agent_id: Agent filter (None = include global)
|
||||||
|
workspace_id: Workspace filter
|
||||||
|
categories: Filter by memory categories
|
||||||
|
include_global: Include memories without agent_id
|
||||||
|
top_k: Number of results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of results with scores
|
||||||
|
"""
|
||||||
|
# Get query embedding
|
||||||
|
query_vector = await get_query_embedding(query)
|
||||||
|
if not query_vector:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Build filter
|
||||||
|
must_conditions = [
|
||||||
|
qmodels.FieldCondition(
|
||||||
|
key="org_id",
|
||||||
|
match=qmodels.MatchValue(value=str(org_id))
|
||||||
|
),
|
||||||
|
qmodels.FieldCondition(
|
||||||
|
key="user_id",
|
||||||
|
match=qmodels.MatchValue(value=str(user_id))
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
if workspace_id:
|
||||||
|
must_conditions.append(
|
||||||
|
qmodels.FieldCondition(
|
||||||
|
key="workspace_id",
|
||||||
|
match=qmodels.MatchValue(value=str(workspace_id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if categories:
|
||||||
|
must_conditions.append(
|
||||||
|
qmodels.FieldCondition(
|
||||||
|
key="category",
|
||||||
|
match=qmodels.MatchAny(any=[c.value for c in categories])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Agent filter with global option
|
||||||
|
if agent_id and not include_global:
|
||||||
|
must_conditions.append(
|
||||||
|
qmodels.FieldCondition(
|
||||||
|
key="agent_id",
|
||||||
|
match=qmodels.MatchValue(value=str(agent_id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif agent_id and include_global:
|
||||||
|
# Include both agent-specific and global (no agent_id)
|
||||||
|
# This requires a should clause
|
||||||
|
pass # Will handle in separate query if needed
|
||||||
|
|
||||||
|
search_filter = qmodels.Filter(must=must_conditions)
|
||||||
|
|
||||||
|
# Search
|
||||||
|
results = self.client.search(
|
||||||
|
collection_name=self.memories_collection,
|
||||||
|
query_vector=query_vector,
|
||||||
|
query_filter=search_filter,
|
||||||
|
limit=top_k,
|
||||||
|
with_payload=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_search",
|
||||||
|
query_preview=query[:50],
|
||||||
|
results_count=len(results)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"point_id": str(r.id),
|
||||||
|
"score": r.score,
|
||||||
|
**r.payload
|
||||||
|
}
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
|
||||||
|
async def delete_memory(self, memory_id: UUID):
|
||||||
|
"""Delete memory from index by memory_id"""
|
||||||
|
self.client.delete(
|
||||||
|
collection_name=self.memories_collection,
|
||||||
|
points_selector=qmodels.FilterSelector(
|
||||||
|
filter=qmodels.Filter(
|
||||||
|
must=[
|
||||||
|
qmodels.FieldCondition(
|
||||||
|
key="memory_id",
|
||||||
|
match=qmodels.MatchValue(value=str(memory_id))
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("memory_deleted_from_index", memory_id=str(memory_id))
|
||||||
|
|
||||||
|
async def get_collection_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get collection statistics"""
|
||||||
|
memories_info = self.client.get_collection(self.memories_collection)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"memories": {
|
||||||
|
"points_count": memories_info.points_count,
|
||||||
|
"vectors_count": memories_info.vectors_count,
|
||||||
|
"indexed_vectors_count": memories_info.indexed_vectors_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
vector_store = VectorStore()
|
||||||
@@ -1,6 +1,32 @@
|
|||||||
fastapi>=0.104.0
|
# DAARION Memory Service
|
||||||
uvicorn[standard]>=0.24.0
|
# Agent memory management with PostgreSQL + Qdrant + Cohere
|
||||||
sqlalchemy>=2.0.0
|
|
||||||
psycopg2-binary>=2.9.0
|
# Web framework
|
||||||
pydantic>=2.0.0
|
fastapi==0.109.0
|
||||||
python-dotenv>=1.0.0
|
uvicorn[standard]==0.27.0
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
|
||||||
|
# Database
|
||||||
|
asyncpg==0.29.0
|
||||||
|
sqlalchemy[asyncio]==2.0.25
|
||||||
|
alembic==1.13.1
|
||||||
|
|
||||||
|
# Vector database
|
||||||
|
qdrant-client==1.7.3
|
||||||
|
|
||||||
|
# Embeddings
|
||||||
|
cohere==4.44
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
httpx==0.26.0
|
||||||
|
tenacity==8.2.3
|
||||||
|
structlog==24.1.0
|
||||||
|
|
||||||
|
# Token counting
|
||||||
|
tiktoken==0.5.2
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest==7.4.4
|
||||||
|
pytest-asyncio==0.23.3
|
||||||
|
|||||||
Reference in New Issue
Block a user