feat: initial project setup — Phase 0

Monorepo pnpm avec NestJS backend en architecture hexagonale.
- Structure hexagonale complète (ports, adapters, domain entities)
- 9 entities TypeORM (Home, User, Device, Credentials, Session, Message, Memory, Timer)
- Migration initiale SQL avec pgvector support
- Docker Compose (PostgreSQL 16 + pgvector + Redis 7)
- Config partagée (tsconfig, ESLint, Prettier)
- Outbound ports définis (STT, TTS, LLM, Cache, Storage, VectorStore)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-03-27 09:01:52 +01:00
commit 674109ea22
45 changed files with 9632 additions and 0 deletions

21
.env.example Normal file
View File

@ -0,0 +1,21 @@
# Database
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=tipote
DATABASE_PASSWORD=tipote
DATABASE_NAME=tipote
DB_PASSWORD=tipote
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
# App
PORT=3000
NODE_ENV=development
ENCRYPTION_KEY=change-me-32-char-encryption-key!
# LLM (à configurer plus tard)
# LLM_API_KEY=
# STT_API_KEY=
# TTS_API_KEY=

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Dependencies
node_modules/
# Build
dist/
build/
.next/
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Test
coverage/
# Docker
docker-compose.override.yml
# TypeORM
*.sqlite

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
24

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"semi": true
}

51
CLAUDE.md Normal file
View File

@ -0,0 +1,51 @@
# Ti-Pote — Project Instructions
## Project Overview
Robot animatronique de bureau personnel — modulaire, imprimé en 3D, propulsé par l'IA.
Monorepo avec pnpm workspaces.
## Stack
- **Backend**: NestJS + TypeScript, architecture hexagonale (ports & adapters)
- **ORM**: TypeORM avec PostgreSQL + pgvector
- **Cache**: Redis (ioredis)
- **Frontend**: Next.js (à venir dans `apps/frontend`)
- **Infra**: Docker Compose (dev), VPS (prod)
## Repository Structure
```
apps/backend/ # NestJS core backend
src/
core/ # Domain (no external deps)
ports/inbound/ # Inbound interfaces
ports/outbound/ # Outbound interfaces (STT, TTS, LLM, storage, cache...)
services/ # Business logic
domain/entities/ # TypeORM entities
tools/ # LLM function/tool definitions
adapters/
inbound/ # REST controllers, WebSocket gateways
outbound/ # Implementations of outbound ports
config/ # App, DB, Redis config
migrations/ # TypeORM migrations (raw SQL)
shared/ # Utils (crypto, token counter, logger)
apps/frontend/ # Next.js frontend (future)
docs/ # Architecture, features, data model, roadmap
```
## Commands
```bash
pnpm dev # Start backend in watch mode
pnpm build # Build backend
pnpm lint # Lint all packages
docker compose up -d # Start PostgreSQL + Redis
```
## Conventions
- Language: TypeScript strict mode
- Entities in `core/domain/entities/` — file pattern: `*.entity.ts`
- Ports in `core/ports/` — file pattern: `*.port.ts`, exported as Symbols
- Adapters implement ports — injected via NestJS DI
- Migrations are manually written SQL (not auto-generated)
- Use `snake_case` for DB columns (TypeORM `@Column({ name: 'snake_case' })`)
- Use `camelCase` for TypeScript properties
- All dates stored as `TIMESTAMPTZ`
- Credentials encrypted with AES-256-GCM before storage

71
README.md Normal file
View File

@ -0,0 +1,71 @@
# Ti-Pote 🤖
**Robot animatronique de bureau personnel — modulaire, imprimé en 3D, propulsé par l'IA.**
Ti-Pote est un compagnon de bureau intelligent qui combine un hardware modulaire imprimé en 3D avec un backend cloud puissant. Le robot agit comme un client léger : il capture la voix et l'environnement de l'utilisateur, tandis que toute l'intelligence (compréhension du langage, function calling, mémoire conversationnelle) est traitée côté serveur.
## Vision
Créer un assistant robotique personnel, open-source et modulaire, capable de :
- Converser de manière fluide et naturelle en vocal
- Exécuter des actions concrètes (agenda, emails, recherche web, minuteurs…)
- Apprendre et se souvenir des préférences de l'utilisateur au fil du temps
- S'adapter physiquement grâce à des modules interchangeables (caméra, base mobile, écran…)
## Architecture
Ti-Pote suit une **architecture hexagonale** avec une séparation claire :
```
┌──────────────┐ WebSocket / REST ┌──────────────────────┐
│ Robot │ ◄──────────────────────────► │ Core Backend │
│ (Thin Client)│ │ (NestJS / TypeScript)│
│ - Micro │ │ - STT / TTS │
│ - Speaker │ │ - LLM + Functions │
│ - Caméra │ │ - Services métier │
│ - Wake Word │ │ - Mémoire │
└──────────────┘ └──────────────────────┘
┌─────────┴─────────┐
│ │
PostgreSQL Redis
+ pgvector
```
Le robot ne fait **aucun traitement lourd** — il capture, streame, et restitue.
## Stack technique
| Composant | Technologie |
|-----------|------------|
| Backend (Core) | TypeScript, NestJS, architecture hexagonale |
| Base de données | PostgreSQL + pgvector |
| Cache / Sessions | Redis |
| Communication | WebSocket (audio), REST (config) |
| Frontend / App | React, Next.js |
| Robot (embarqué) | Raspberry Pi, OpenWakeWord, ReSpeaker |
| STT | Deepgram / Whisper |
| LLM | Configurable (OpenAI, Anthropic, etc.) |
| TTS | ElevenLabs / Azure TTS |
## Documentation
| Document | Description |
|----------|-------------|
| [Architecture](docs/architecture.md) | Architecture hexagonale, services, flux de données |
| [Infrastructure](docs/infrastructure.md) | Déploiement cloud, bases de données, monitoring |
| [Fonctionnalités](docs/features.md) | Liste complète des features et cas d'usage |
| [Modèle de données](docs/data-model.md) | Schéma BDD, entités, relations |
| [Mémoire conversationnelle](docs/memory-system.md) | Système de mémoire à 3 niveaux |
| [Hardware](docs/hardware.md) | Propositions de composants et modules |
| [Roadmap](docs/roadmap.md) | Phases de développement et priorités |
## Équipe
- **Arthur** — Software & Backend (TypeScript, API, applications)
- **Juliann** — Hardware & Design (impression 3D, PCB, Arduino)
## Licence
À définir.

25
apps/backend/.eslintrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
},
};

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

58
apps/backend/package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "@ti-pote/backend",
"version": "0.0.1",
"private": true,
"type": "commonjs",
"description": "Ti-Pote Core Backend — NestJS",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "nest start",
"start:prod": "node dist/main",
"start:debug": "nest start --debug --watch",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "tsx ./node_modules/typeorm/cli.js",
"migration:generate": "pnpm typeorm migration:generate -d src/config/typeorm.config.ts",
"migration:run": "pnpm typeorm migration:run -d src/config/typeorm.config.ts",
"migration:revert": "pnpm typeorm migration:revert -d src/config/typeorm.config.ts"
},
"dependencies": {
"@nestjs/common": "^11.1.17",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.17",
"@nestjs/platform-express": "^11.1.17",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.3.1",
"ioredis": "^5.10.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.9",
"@nestjs/testing": "^11.1.17",
"@types/jest": "^30.0.0",
"@types/node": "^25.5.0",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"jest": "^30.3.0",
"prettier": "^3.8.1",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { typeormConfig } from './config/typeorm.config';
import { redisConfig } from './config/redis.config';
import { appConfig } from './config/app.config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, redisConfig],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => typeormConfig(configService),
}),
],
controllers: [],
providers: [],
})
export class AppModule {}

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export const appConfig = registerAs('app', () => ({
port: parseInt(process.env.PORT || '3000', 10),
environment: process.env.NODE_ENV || 'development',
encryptionKey: process.env.ENCRYPTION_KEY,
}));

View File

@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export const redisConfig = registerAs('redis', () => ({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
}));

View File

@ -0,0 +1,31 @@
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { DataSource, DataSourceOptions } from 'typeorm';
import * as dotenv from 'dotenv';
dotenv.config();
function env(key: string, fallback: string): string {
return process.env[key] || fallback;
}
export function typeormConfig(configService?: ConfigService): TypeOrmModuleOptions {
const get = (key: string, fallback: string): string =>
configService ? (configService.get<string>(key) ?? fallback) : env(key, fallback);
return {
type: 'postgres',
host: get('DATABASE_HOST', 'localhost'),
port: parseInt(get('DATABASE_PORT', '5432'), 10),
username: get('DATABASE_USER', 'tipote'),
password: get('DATABASE_PASSWORD', 'tipote'),
database: get('DATABASE_NAME', 'tipote'),
entities: [__dirname + '/../core/domain/entities/*.entity{.ts,.js}'],
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
synchronize: false,
logging: get('NODE_ENV', 'development') === 'development',
};
}
// DataSource instance for CLI migrations
export default new DataSource(typeormConfig() as DataSourceOptions);

View File

@ -0,0 +1,69 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
import { Device } from './device.entity';
import { Message } from './message.entity';
import { MemoryEntry } from './memory-entry.entity';
export enum SessionStatus {
ACTIVE = 'active',
ENDED = 'ended',
TIMEOUT = 'timeout',
}
@Entity('conversation_sessions')
export class ConversationSession {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid', name: 'user_id' })
userId!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
@Column({ type: 'uuid', name: 'device_id' })
deviceId!: string;
@ManyToOne(() => Device, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'device_id' })
device!: Device;
@Column({ type: 'enum', enum: SessionStatus, default: SessionStatus.ACTIVE })
status!: SessionStatus;
@Column({ type: 'timestamptz', name: 'started_at' })
startedAt!: Date;
@Column({ type: 'timestamptz', nullable: true, name: 'ended_at' })
endedAt!: Date | null;
@Column({ type: 'text', nullable: true })
summary!: string | null;
@Column({ type: 'jsonb', nullable: true, name: 'extracted_facts' })
extractedFacts!: Record<string, unknown> | null;
@Column({ type: 'integer', default: 0, name: 'message_count' })
messageCount!: number;
@Column({ type: 'integer', default: 0, name: 'total_tokens' })
totalTokens!: number;
@OneToMany(() => Message, (message) => message.session)
messages!: Message[];
@OneToMany(() => MemoryEntry, (memory) => memory.session)
memories!: MemoryEntry[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@ -0,0 +1,53 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Home } from './home.entity';
export enum DeviceStatus {
ONLINE = 'online',
OFFLINE = 'offline',
UPDATING = 'updating',
}
@Entity('devices')
export class Device {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid', name: 'home_id' })
homeId!: string;
@ManyToOne(() => Home, (home) => home.devices, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'home_id' })
home!: Home;
@Column({ type: 'varchar', length: 100 })
name!: string;
@Column({ type: 'varchar', length: 255, name: 'device_token_hash' })
deviceTokenHash!: string;
@Column({ type: 'jsonb', default: {} })
config!: Record<string, unknown>;
@Column({ type: 'enum', enum: DeviceStatus, default: DeviceStatus.OFFLINE })
status!: DeviceStatus;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'firmware_version' })
firmwareVersion!: string | null;
@Column({ type: 'timestamptz', nullable: true, name: 'last_seen_at' })
lastSeenAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,31 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { User } from './user.entity';
import { Device } from './device.entity';
@Entity('homes')
export class Home {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 100 })
name!: string;
@OneToMany(() => User, (user) => user.home)
users!: User[];
@OneToMany(() => Device, (device) => device.home)
devices!: Device[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,37 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { MemoryEntry } from './memory-entry.entity';
import { User } from './user.entity';
@Entity('memory_embeddings')
export class MemoryEmbedding {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid', name: 'memory_id' })
memoryId!: string;
@ManyToOne(() => MemoryEntry, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'memory_id' })
memory!: MemoryEntry;
@Column({ type: 'uuid', name: 'user_id' })
userId!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
// pgvector column — raw SQL in migration, TypeORM stores as string
@Column({ type: 'varchar', nullable: true })
embedding!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@ -0,0 +1,62 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
import { ConversationSession } from './conversation-session.entity';
export enum MemoryType {
FACT = 'fact',
PREFERENCE = 'preference',
EPISODE = 'episode',
PROFILE = 'profile',
}
@Entity('memory_entries')
export class MemoryEntry {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid', name: 'user_id' })
userId!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
@Column({ type: 'uuid', nullable: true, name: 'session_id' })
sessionId!: string | null;
@ManyToOne(() => ConversationSession, (session) => session.memories, {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'session_id' })
session!: ConversationSession | null;
@Column({ type: 'enum', enum: MemoryType })
type!: MemoryType;
@Column({ type: 'text' })
content!: string;
@Column({ type: 'text', array: true, default: '{}' })
tags!: string[];
@Column({ type: 'float', default: 0.5 })
importance!: number;
@Column({ type: 'boolean', default: true, name: 'is_active' })
isActive!: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,50 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ConversationSession } from './conversation-session.entity';
export enum MessageRole {
USER = 'user',
ASSISTANT = 'assistant',
SYSTEM = 'system',
TOOL = 'tool',
}
@Entity('messages')
export class Message {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid', name: 'session_id' })
sessionId!: string;
@ManyToOne(() => ConversationSession, (session) => session.messages, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'session_id' })
session!: ConversationSession;
@Column({ type: 'enum', enum: MessageRole })
role!: MessageRole;
@Column({ type: 'text' })
content!: string;
@Column({ type: 'jsonb', nullable: true, name: 'tool_calls' })
toolCalls!: Record<string, unknown>[] | null;
@Column({ type: 'jsonb', nullable: true, name: 'tool_result' })
toolResult!: Record<string, unknown> | null;
@Column({ type: 'varchar', length: 500, nullable: true, name: 'audio_url' })
audioUrl!: string | null;
@Column({ type: 'integer', default: 0, name: 'tokens_used' })
tokensUsed!: number;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@ -0,0 +1,59 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
import { Device } from './device.entity';
export enum TimerType {
TIMER = 'timer',
ALARM = 'alarm',
}
export enum TimerStatus {
ACTIVE = 'active',
TRIGGERED = 'triggered',
CANCELLED = 'cancelled',
}
@Entity('timers')
export class Timer {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid', name: 'user_id' })
userId!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
@Column({ type: 'uuid', name: 'device_id' })
deviceId!: string;
@ManyToOne(() => Device, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'device_id' })
device!: Device;
@Column({ type: 'enum', enum: TimerType })
type!: TimerType;
@Column({ type: 'varchar', length: 200, nullable: true })
label!: string | null;
@Column({ type: 'timestamptz', name: 'trigger_at' })
triggerAt!: Date;
@Column({ type: 'varchar', length: 50, nullable: true })
recurrence!: string | null;
@Column({ type: 'enum', enum: TimerStatus, default: TimerStatus.ACTIVE })
status!: TimerStatus;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@ -0,0 +1,49 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
export enum ServiceType {
GOOGLE = 'google',
APPLE = 'apple',
MICROSOFT = 'microsoft',
SMTP = 'smtp',
WHATSAPP = 'whatsapp',
}
@Entity('user_service_credentials')
export class UserServiceCredential {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid', name: 'user_id' })
userId!: string;
@ManyToOne(() => User, (user) => user.serviceCredentials, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
@Column({ type: 'enum', enum: ServiceType, name: 'service_type' })
serviceType!: ServiceType;
@Column({ type: 'text', name: 'encrypted_tokens' })
encryptedTokens!: string;
@Column({ type: 'jsonb', default: {} })
metadata!: Record<string, unknown>;
@Column({ type: 'timestamptz', nullable: true, name: 'expires_at' })
expiresAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,54 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';
import { Home } from './home.entity';
import { UserServiceCredential } from './user-service-credential.entity';
export enum UserRole {
OWNER = 'owner',
MEMBER = 'member',
}
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid' })
homeId!: string;
@ManyToOne(() => Home, (home) => home.users, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'home_id' })
home!: Home;
@Column({ type: 'varchar', length: 255, unique: true })
email!: string;
@Column({ type: 'varchar', length: 255, name: 'password_hash' })
passwordHash!: string;
@Column({ type: 'varchar', length: 100, name: 'display_name' })
displayName!: string;
@Column({ type: 'enum', enum: UserRole, default: UserRole.MEMBER })
role!: UserRole;
@Column({ type: 'jsonb', default: {} })
preferences!: Record<string, unknown>;
@OneToMany(() => UserServiceCredential, (cred) => cred.user)
serviceCredentials!: UserServiceCredential[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -0,0 +1,10 @@
export interface ICachePort {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
del(key: string): Promise<void>;
lpush(key: string, value: string): Promise<void>;
lrange(key: string, start: number, stop: number): Promise<string[]>;
expire(key: string, ttlSeconds: number): Promise<void>;
}
export const CACHE_PORT = Symbol('CACHE_PORT');

View File

@ -0,0 +1,30 @@
export interface LLMMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
toolCallId?: string;
toolCalls?: LLMToolCall[];
}
export interface LLMToolCall {
id: string;
name: string;
arguments: string;
}
export interface LLMToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface LLMResponse {
content: string | null;
toolCalls: LLMToolCall[];
tokensUsed: number;
}
export interface ILLMPort {
chat(messages: LLMMessage[], tools?: LLMToolDefinition[]): Promise<LLMResponse>;
}
export const LLM_PORT = Symbol('LLM_PORT');

View File

@ -0,0 +1,7 @@
export interface IStoragePort {
findById<T>(entity: new () => T, id: string): Promise<T | null>;
save<T>(entity: T): Promise<T>;
remove<T>(entity: T): Promise<void>;
}
export const STORAGE_PORT = Symbol('STORAGE_PORT');

View File

@ -0,0 +1,13 @@
export interface TranscriptionResult {
text: string;
confidence: number;
isFinal: boolean;
}
export interface ISTTPort {
transcribe(audioChunk: Buffer, sampleRate: number): Promise<TranscriptionResult>;
startStream(onResult: (result: TranscriptionResult) => void): void;
endStream(): Promise<void>;
}
export const STT_PORT = Symbol('STT_PORT');

View File

@ -0,0 +1,6 @@
export interface ITTSPort {
synthesize(text: string, voice?: string): Promise<Buffer>;
synthesizeStream(text: string, voice?: string, onChunk?: (chunk: Buffer) => void): Promise<void>;
}
export const TTS_PORT = Symbol('TTS_PORT');

View File

@ -0,0 +1,14 @@
export interface SimilarityResult {
id: string;
content: string;
similarity: number;
metadata?: Record<string, unknown>;
}
export interface IVectorStorePort {
storeEmbedding(id: string, userId: string, embedding: number[], content: string): Promise<void>;
searchSimilar(userId: string, queryEmbedding: number[], topK: number, threshold?: number): Promise<SimilarityResult[]>;
deleteEmbedding(id: string): Promise<void>;
}
export const VECTOR_STORE_PORT = Symbol('VECTOR_STORE_PORT');

22
apps/backend/src/main.ts Normal file
View File

@ -0,0 +1,22 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.enableCors();
const port = process.env.PORT || 3000;
await app.listen(port);
console.log(`Ti-Pote Core running on port ${port}`);
}
bootstrap();

View File

@ -0,0 +1,217 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialSchema1711500000000 implements MigrationInterface {
name = 'InitialSchema1711500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Enable extensions
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "vector"`);
// Create enum types
await queryRunner.query(`CREATE TYPE "user_role_enum" AS ENUM('owner', 'member')`);
await queryRunner.query(
`CREATE TYPE "device_status_enum" AS ENUM('online', 'offline', 'updating')`,
);
await queryRunner.query(
`CREATE TYPE "service_type_enum" AS ENUM('google', 'apple', 'microsoft', 'smtp', 'whatsapp')`,
);
await queryRunner.query(
`CREATE TYPE "session_status_enum" AS ENUM('active', 'ended', 'timeout')`,
);
await queryRunner.query(
`CREATE TYPE "message_role_enum" AS ENUM('user', 'assistant', 'system', 'tool')`,
);
await queryRunner.query(
`CREATE TYPE "memory_type_enum" AS ENUM('fact', 'preference', 'episode', 'profile')`,
);
await queryRunner.query(`CREATE TYPE "timer_type_enum" AS ENUM('timer', 'alarm')`);
await queryRunner.query(
`CREATE TYPE "timer_status_enum" AS ENUM('active', 'triggered', 'cancelled')`,
);
// Homes
await queryRunner.query(`
CREATE TABLE "homes" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"name" VARCHAR(100) NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Users
await queryRunner.query(`
CREATE TABLE "users" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"home_id" UUID NOT NULL REFERENCES "homes"("id") ON DELETE CASCADE,
"email" VARCHAR(255) NOT NULL UNIQUE,
"password_hash" VARCHAR(255) NOT NULL,
"display_name" VARCHAR(100) NOT NULL,
"role" "user_role_enum" NOT NULL DEFAULT 'member',
"preferences" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Devices
await queryRunner.query(`
CREATE TABLE "devices" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"home_id" UUID NOT NULL REFERENCES "homes"("id") ON DELETE CASCADE,
"name" VARCHAR(100) NOT NULL,
"device_token_hash" VARCHAR(255) NOT NULL,
"config" JSONB NOT NULL DEFAULT '{}',
"status" "device_status_enum" NOT NULL DEFAULT 'offline',
"firmware_version" VARCHAR(20),
"last_seen_at" TIMESTAMPTZ,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// User service credentials
await queryRunner.query(`
CREATE TABLE "user_service_credentials" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"service_type" "service_type_enum" NOT NULL,
"encrypted_tokens" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"expires_at" TIMESTAMPTZ,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Conversation sessions
await queryRunner.query(`
CREATE TABLE "conversation_sessions" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"device_id" UUID NOT NULL REFERENCES "devices"("id") ON DELETE CASCADE,
"status" "session_status_enum" NOT NULL DEFAULT 'active',
"started_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"ended_at" TIMESTAMPTZ,
"summary" TEXT,
"extracted_facts" JSONB,
"message_count" INTEGER NOT NULL DEFAULT 0,
"total_tokens" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Messages
await queryRunner.query(`
CREATE TABLE "messages" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"session_id" UUID NOT NULL REFERENCES "conversation_sessions"("id") ON DELETE CASCADE,
"role" "message_role_enum" NOT NULL,
"content" TEXT NOT NULL,
"tool_calls" JSONB,
"tool_result" JSONB,
"audio_url" VARCHAR(500),
"tokens_used" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Memory entries
await queryRunner.query(`
CREATE TABLE "memory_entries" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"session_id" UUID REFERENCES "conversation_sessions"("id") ON DELETE SET NULL,
"type" "memory_type_enum" NOT NULL,
"content" TEXT NOT NULL,
"tags" TEXT[] NOT NULL DEFAULT '{}',
"importance" FLOAT NOT NULL DEFAULT 0.5,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Memory embeddings
await queryRunner.query(`
CREATE TABLE "memory_embeddings" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"memory_id" UUID NOT NULL REFERENCES "memory_entries"("id") ON DELETE CASCADE,
"user_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"embedding" vector(1536),
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Timers
await queryRunner.query(`
CREATE TABLE "timers" (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"device_id" UUID NOT NULL REFERENCES "devices"("id") ON DELETE CASCADE,
"type" "timer_type_enum" NOT NULL,
"label" VARCHAR(200),
"trigger_at" TIMESTAMPTZ NOT NULL,
"recurrence" VARCHAR(50),
"status" "timer_status_enum" NOT NULL DEFAULT 'active',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Indexes
await queryRunner.query(`CREATE INDEX "idx_users_home" ON "users"("home_id")`);
await queryRunner.query(`CREATE INDEX "idx_users_email" ON "users"("email")`);
await queryRunner.query(`CREATE INDEX "idx_devices_home" ON "devices"("home_id")`);
await queryRunner.query(
`CREATE INDEX "idx_credentials_user" ON "user_service_credentials"("user_id")`,
);
await queryRunner.query(
`CREATE INDEX "idx_sessions_user" ON "conversation_sessions"("user_id")`,
);
await queryRunner.query(
`CREATE INDEX "idx_sessions_device" ON "conversation_sessions"("device_id")`,
);
await queryRunner.query(`CREATE INDEX "idx_messages_session" ON "messages"("session_id")`);
await queryRunner.query(
`CREATE INDEX "idx_memory_entries_user" ON "memory_entries"("user_id")`,
);
await queryRunner.query(
`CREATE INDEX "idx_memory_entries_active" ON "memory_entries"("user_id") WHERE "is_active" = true`,
);
await queryRunner.query(
`CREATE INDEX "idx_memory_embeddings_user" ON "memory_embeddings"("user_id")`,
);
await queryRunner.query(`CREATE INDEX "idx_timers_user" ON "timers"("user_id")`);
await queryRunner.query(
`CREATE INDEX "idx_timers_active" ON "timers"("user_id") WHERE "status" = 'active'`,
);
// pgvector similarity search index
await queryRunner.query(`
CREATE INDEX "idx_memory_embeddings_vector" ON "memory_embeddings"
USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "timers" CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS "memory_embeddings" CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS "memory_entries" CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS "messages" CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS "conversation_sessions" CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS "user_service_credentials" CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS "devices" CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS "users" CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS "homes" CASCADE`);
await queryRunner.query(`DROP TYPE IF EXISTS "timer_status_enum"`);
await queryRunner.query(`DROP TYPE IF EXISTS "timer_type_enum"`);
await queryRunner.query(`DROP TYPE IF EXISTS "memory_type_enum"`);
await queryRunner.query(`DROP TYPE IF EXISTS "message_role_enum"`);
await queryRunner.query(`DROP TYPE IF EXISTS "session_status_enum"`);
await queryRunner.query(`DROP TYPE IF EXISTS "service_type_enum"`);
await queryRunner.query(`DROP TYPE IF EXISTS "device_status_enum"`);
await queryRunner.query(`DROP TYPE IF EXISTS "user_role_enum"`);
}
}

View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"baseUrl": "./"
},
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

25
docker-compose.yml Normal file
View File

@ -0,0 +1,25 @@
services:
postgres:
image: pgvector/pgvector:pg16
ports:
- '5432:5432'
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: tipote
POSTGRES_USER: tipote
POSTGRES_PASSWORD: ${DB_PASSWORD:-tipote}
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
- redisdata:/data
command: redis-server --appendonly yes
restart: unless-stopped
volumes:
pgdata:
redisdata:

300
docs/architecture.md Normal file
View File

@ -0,0 +1,300 @@
# Architecture Logicielle
## Principes directeurs
Ti-Pote suit une **architecture hexagonale** (Ports & Adapters). L'objectif est de découpler totalement la logique métier des détails d'implémentation (bases de données, APIs tierces, protocoles de communication). Chaque brique peut être remplacée sans impacter le reste du système.
### Pourquoi l'architecture hexagonale ?
Le projet intègre de nombreux services externes (STT, TTS, LLM, Google Calendar, SMTP…) qui sont susceptibles de changer. En isolant chaque intégration derrière un port (interface TypeScript), on peut swapper un provider sans toucher au code métier. Par exemple, passer de Deepgram à Whisper pour le STT ne modifie que l'adaptateur, pas le service de conversation.
## Vue d'ensemble
```
┌─────────────────────────────────────────────┐
│ ADAPTATEURS ENTRANTS │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │WebSocket │ │ REST API │ │ Web App │ │
│ │(Robot) │ │(Config) │ │(React) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼──────────────┼─────────────┼────────┘
│ │ │
┌───────▼──────────────▼─────────────▼────────┐
│ │
│ PORTS ENTRANTS │
│ (Interfaces TypeScript) │
│ │
│ IConversationPort IConfigPort │
│ IAuthPort IDevicePort │
│ │
├─────────────────────────────────────────────┤
│ │
│ CORE (DOMAINE) │
│ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Conversation │ │ Calendar │ │
│ │ Service │ │ Service │ │
│ └───────────────┘ └───────────────┘ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Mail │ │ Timer/Alarm │ │
│ │ Service │ │ Service │ │
│ └───────────────┘ └───────────────┘ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Memory │ │ WebSearch │ │
│ │ Service │ │ Service │ │
│ └───────────────┘ └───────────────┘ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ User │ │ Device │ │
│ │ Service │ │ Service │ │
│ └───────────────┘ └───────────────┘ │
│ ┌───────────────┐ │
│ │ Context │ │
│ │ Builder │ │
│ └───────────────┘ │
│ │
├─────────────────────────────────────────────┤
│ │
│ PORTS SORTANTS │
│ (Interfaces TypeScript) │
│ │
│ ISTTPort ITTSPort │
│ ILLMPort ICalendarPort │
│ IMailPort ISearchPort │
│ IStoragePort IVectorStorePort │
│ ICachePort INotificationPort │
│ │
├─────────────────────────────────────────────┤
│ ADAPTATEURS SORTANTS │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Deepgram │ │ElevenLabs│ │OpenAI │ │
│ │(STT) │ │(TTS) │ │(LLM) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Google │ │SMTP │ │PostgreSQL│ │
│ │Calendar │ │(Mail) │ │(Storage) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ │
│ │Redis │ │pgvector │ │
│ │(Cache) │ │(Vectors) │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────┘
```
## Services du Core
### ConversationService
Le service central qui orchestre chaque échange vocal. Responsabilités :
- Recevoir l'audio streamé depuis le robot via WebSocket
- Déléguer la transcription au port STT
- Construire le contexte (via ContextBuilder) en injectant la mémoire pertinente
- Envoyer le prompt au LLM avec les définitions de functions/tools
- Interpréter la réponse du LLM : réponse directe ou function call
- Si function call → exécuter via le service concerné → renvoyer le résultat au LLM
- Envoyer la réponse texte au TTS et streamer l'audio de retour
```
Audio entrant (robot)
┌──────────┐ texte ┌──────────────┐ prompt ┌─────────┐
│ STT │ ──────────► │ Context │ ───────────► │ LLM │
│ │ │ Builder │ │ │
└──────────┘ └──────────────┘ └────┬────┘
┌─────────────────────────────┤
│ │
function call réponse directe
│ │
▼ ▼
┌──────────────┐ ┌──────────┐
│ Service │ │ TTS │
│ métier │ │ │
│ (Calendar, │ └────┬─────┘
│ Mail, etc.) │ │
└──────┬───────┘ ▼
│ Audio sortant (robot)
Résultat → LLM → TTS → Audio
```
### ContextBuilder
Service dédié à la construction du prompt envoyé au LLM. Il assemble :
1. **System prompt** — Personnalité de Ti-Pote, instructions, capacités disponibles
2. **Profil utilisateur** — Faits connus sur l'utilisateur (préférences, contacts fréquents…)
3. **Souvenirs pertinents** — Récupérés par recherche sémantique dans pgvector
4. **Historique de session** — Les N derniers messages de la conversation active (depuis Redis)
5. **Définitions de tools** — Les functions que le LLM peut appeler
6. **Message courant** — La transcription de ce que l'utilisateur vient de dire
Le ContextBuilder respecte un **budget de tokens** configurable. Si le contexte dépasse le budget, il priorise : message courant > historique récent > profil > souvenirs, et tronque les éléments les moins prioritaires.
### CalendarService
Gestion des rendez-vous et événements. Expose des méthodes métier (createEvent, listEvents, findFreeSlots…) qui sont mappées comme tools pour le LLM. L'adaptateur sortant implémente l'intégration OAuth2 avec Google Calendar (et Apple Calendar à terme).
### MailService
Envoi et lecture d'emails. Supporte SMTP pour l'envoi et IMAP pour la lecture. À terme, intégration WhatsApp et autres messageries.
### TimerAlarmService
Gestion des minuteurs et alarmes. Les timers sont stockés en Redis avec un TTL. Quand le timer expire, le robot est notifié via WebSocket pour jouer un son ou annoncer vocalement la fin du timer.
### MemoryService
Gère les 3 niveaux de mémoire (session, épisodique, sémantique). Voir [memory-system.md](memory-system.md) pour le détail complet.
### WebSearchService
Recherche sur internet à la demande de l'utilisateur. Utilise une API de recherche (SearXNG auto-hébergé ou API tierce) et peut extraire le contenu des pages pour le résumer.
### UserService
Gestion des utilisateurs, authentification, préférences. Stockage des credentials chiffrés (AES-256) pour les services tiers.
### DeviceService
Gestion des robots (devices). Enregistrement, statut de connexion, configuration (wake word, volume, langue…). Chaque robot maintient une connexion WebSocket persistante identifiée par un device ID.
## Structure NestJS proposée
```
src/
├── main.ts
├── app.module.ts
├── core/ # Domaine métier (aucune dépendance externe)
│ ├── ports/
│ │ ├── inbound/ # Ports entrants (interfaces)
│ │ │ ├── conversation.port.ts
│ │ │ ├── config.port.ts
│ │ │ ├── auth.port.ts
│ │ │ └── device.port.ts
│ │ └── outbound/ # Ports sortants (interfaces)
│ │ ├── stt.port.ts
│ │ ├── tts.port.ts
│ │ ├── llm.port.ts
│ │ ├── calendar.port.ts
│ │ ├── mail.port.ts
│ │ ├── search.port.ts
│ │ ├── storage.port.ts
│ │ ├── vector-store.port.ts
│ │ └── cache.port.ts
│ ├── services/
│ │ ├── conversation.service.ts
│ │ ├── context-builder.service.ts
│ │ ├── calendar.service.ts
│ │ ├── mail.service.ts
│ │ ├── timer-alarm.service.ts
│ │ ├── memory.service.ts
│ │ ├── web-search.service.ts
│ │ ├── user.service.ts
│ │ └── device.service.ts
│ ├── domain/ # Entités et value objects
│ │ ├── user.entity.ts
│ │ ├── device.entity.ts
│ │ ├── conversation-session.entity.ts
│ │ ├── memory-entry.entity.ts
│ │ └── ...
│ └── tools/ # Définitions des functions pour le LLM
│ ├── calendar.tools.ts
│ ├── mail.tools.ts
│ ├── timer.tools.ts
│ ├── search.tools.ts
│ └── index.ts
├── adapters/
│ ├── inbound/ # Adaptateurs entrants
│ │ ├── websocket/
│ │ │ └── robot.gateway.ts # WebSocket Gateway NestJS
│ │ ├── rest/
│ │ │ ├── config.controller.ts
│ │ │ ├── auth.controller.ts
│ │ │ └── device.controller.ts
│ │ └── web/ # Serveur du frontend
│ │ └── static.module.ts
│ └── outbound/ # Adaptateurs sortants
│ ├── stt/
│ │ ├── deepgram.adapter.ts
│ │ └── whisper.adapter.ts
│ ├── tts/
│ │ ├── elevenlabs.adapter.ts
│ │ └── azure-tts.adapter.ts
│ ├── llm/
│ │ ├── openai.adapter.ts
│ │ └── anthropic.adapter.ts
│ ├── calendar/
│ │ └── google-calendar.adapter.ts
│ ├── mail/
│ │ └── smtp.adapter.ts
│ ├── search/
│ │ └── searxng.adapter.ts
│ ├── storage/
│ │ └── postgresql.adapter.ts
│ ├── vector-store/
│ │ └── pgvector.adapter.ts
│ └── cache/
│ └── redis.adapter.ts
├── config/ # Configuration applicative
│ ├── database.config.ts
│ ├── redis.config.ts
│ ├── llm.config.ts
│ └── app.config.ts
└── shared/ # Utilitaires partagés
├── crypto.util.ts # Chiffrement AES-256
├── token-counter.util.ts # Comptage de tokens
└── logger.ts
```
## Communication Robot ↔ Core
### WebSocket (audio bidirectionnel)
Le robot maintient une connexion WebSocket permanente avec le core. Le protocole :
1. **Authentification** — À la connexion, le robot envoie un `device_token` JWT. Le core valide et associe la session au device + user.
2. **Streaming audio entrant** — Le robot envoie des chunks audio (PCM 16kHz 16bit mono) en continu pendant que l'utilisateur parle.
3. **Événements de contrôle** — Wake word détecté, fin de parole (VAD), interruption utilisateur.
4. **Streaming audio sortant** — Le core streame les chunks audio TTS au fur et à mesure de la génération.
5. **Notifications** — Timer expiré, rappel de rendez-vous, alertes.
```typescript
// Messages WebSocket (types)
type RobotMessage =
| { type: 'audio_chunk'; data: Buffer; sampleRate: number }
| { type: 'wake_word_detected' }
| { type: 'speech_end' } // VAD détecte fin de parole
| { type: 'user_interrupt' } // L'utilisateur parle pendant la réponse
type CoreMessage =
| { type: 'audio_chunk'; data: Buffer }
| { type: 'response_start' }
| { type: 'response_end' }
| { type: 'notification'; payload: NotificationPayload }
| { type: 'status'; state: 'listening' | 'thinking' | 'speaking' }
```
### REST API (configuration)
Utilisée par l'app web/mobile pour toute la configuration :
- CRUD utilisateurs et devices
- Gestion des credentials OAuth (Google, Apple, etc.)
- Configuration du robot (wake word, volume, voix TTS, modèle LLM…)
- Consultation de l'historique et de la mémoire
- Gestion des timers et alarmes
## Gestion des erreurs et résilience
- **Circuit breaker** sur les appels aux services externes (STT, TTS, LLM) — si un provider tombe, le système peut fallback sur un autre (ex: Deepgram → Whisper local).
- **Retry avec backoff exponentiel** sur les appels API.
- **Queue de messages** pour les function calls non critiques (ex: envoi d'email) — si ça échoue, on retry sans bloquer la conversation.
- **Mode dégradé offline** — le robot peut toujours gérer les timers, alarmes, et commandes basiques en local si le core est injoignable.

256
docs/data-model.md Normal file
View File

@ -0,0 +1,256 @@
# Modèle de données
## Diagramme des entités
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Home │ 1───N │ User │ 1───N │ UserService │
│ │ │ │ │ Credential │
│ id │ │ id │ │ │
│ name │ │ home_id (FK) │ │ id │
│ created_at │ │ email │ │ user_id (FK) │
│ updated_at │ │ password_hash│ │ service_type │
└──────────────┘ │ display_name │ │ encrypted_ │
│ role │ │ tokens │
│ preferences │ │ metadata │
│ created_at │ │ created_at │
│ updated_at │ │ updated_at │
└──────┬───────┘ └──────────────┘
│ 1───N
┌──────────────┐ ┌──────────────┐
│ Device │ │Conversation │
│ │ │ Session │
│ id │ │ │
│ home_id (FK) │ │ id │
│ name │ │ user_id (FK) │
│ device_token │ │ device_id(FK)│
│ config │ │ status │
│ status │ │ started_at │
│ last_seen_at │ │ ended_at │
│ created_at │ │ summary │
│ updated_at │ │ created_at │
└──────────────┘ └──────┬───────┘
│ 1───N
┌──────────────┐ ┌──────────────┐
│ Message │ │MemoryEntry │
│ │ │ │
│ id │ │ id │
│ session_id │ │ user_id (FK) │
│ (FK) │ │ session_id │
│ role │ │ (FK, opt) │
│ content │ │ type │
│ tool_calls │ │ content │
│ audio_url │ │ tags │
│ created_at │ │ created_at │
└──────────────┘ │ updated_at │
└──────────────┘
┌──────────────┐ ┌──────────────┐
│MemoryEmbed │ │ Timer │
│ │ │ │
│ id │ │ id │
│ memory_id(FK)│ │ user_id (FK) │
│ user_id (FK) │ │ device_id(FK)│
│ embedding │ │ type │
│ vector(1536) │ label │
│ created_at │ │ trigger_at │
└──────────────┘ │ recurrence │
│ status │
│ created_at │
└──────────────┘
```
## Détail des tables
### Home
Représente un foyer. Permet de regrouper plusieurs utilisateurs et robots dans un même espace logique (inspiré du modèle Google Home).
| Colonne | Type | Description |
|---------|------|-------------|
| id | UUID (PK) | Identifiant unique |
| name | VARCHAR(100) | Nom du foyer ("Maison d'Arthur") |
| created_at | TIMESTAMPTZ | Date de création |
| updated_at | TIMESTAMPTZ | Dernière modification |
### User
Un utilisateur du système. Rattaché à un Home.
| Colonne | Type | Description |
|---------|------|-------------|
| id | UUID (PK) | Identifiant unique |
| home_id | UUID (FK → Home) | Foyer de l'utilisateur |
| email | VARCHAR(255) UNIQUE | Email de connexion |
| password_hash | VARCHAR(255) | Hash bcrypt du mot de passe |
| display_name | VARCHAR(100) | Nom affiché |
| role | ENUM('owner', 'member') | Rôle dans le foyer |
| preferences | JSONB | Préférences utilisateur (langue, timezone, etc.) |
| created_at | TIMESTAMPTZ | Date de création |
| updated_at | TIMESTAMPTZ | Dernière modification |
Le champ `preferences` est un JSONB flexible pour stocker les préférences sans migration de schéma à chaque ajout :
```json
{
"language": "fr",
"timezone": "Europe/Paris",
"tts_voice": "elevenlabs_rachel",
"llm_model": "gpt-4",
"wake_word": "hey-ti-pote",
"do_not_disturb": {
"enabled": true,
"start": "23:00",
"end": "07:00"
}
}
```
### Device
Un robot Ti-Pote physique. Rattaché à un Home, partagé entre les Users du Home.
| Colonne | Type | Description |
|---------|------|-------------|
| id | UUID (PK) | Identifiant unique |
| home_id | UUID (FK → Home) | Foyer du robot |
| name | VARCHAR(100) | Nom du robot ("Ti-Pote Bureau") |
| device_token_hash | VARCHAR(255) | Hash du JWT token du device |
| config | JSONB | Configuration hardware (modules actifs, volume, etc.) |
| status | ENUM('online', 'offline', 'updating') | Statut actuel |
| firmware_version | VARCHAR(20) | Version du firmware |
| last_seen_at | TIMESTAMPTZ | Dernier ping reçu |
| created_at | TIMESTAMPTZ | Date d'enregistrement |
| updated_at | TIMESTAMPTZ | Dernière modification |
Le champ `config` :
```json
{
"modules": {
"camera": true,
"mobile_base": false,
"screen": true
},
"volume": 75,
"led_brightness": 50,
"mic_sensitivity": "auto"
}
```
### UserServiceCredential
Tokens OAuth et credentials pour les services tiers, chiffrés en AES-256-GCM.
| Colonne | Type | Description |
|---------|------|-------------|
| id | UUID (PK) | Identifiant unique |
| user_id | UUID (FK → User) | Propriétaire |
| service_type | ENUM('google', 'apple', 'microsoft', 'smtp', 'whatsapp') | Type de service |
| encrypted_tokens | TEXT | Tokens chiffrés (access + refresh) |
| metadata | JSONB | Infos non sensibles (email du compte, scopes, etc.) |
| expires_at | TIMESTAMPTZ | Expiration de l'access token (pour refresh proactif) |
| created_at | TIMESTAMPTZ | Date de liaison |
| updated_at | TIMESTAMPTZ | Dernier refresh |
### ConversationSession
Une session de conversation (du wake word au silence / fin de conversation).
| Colonne | Type | Description |
|---------|------|-------------|
| id | UUID (PK) | Identifiant unique |
| user_id | UUID (FK → User) | Utilisateur qui parle |
| device_id | UUID (FK → Device) | Robot utilisé |
| status | ENUM('active', 'ended', 'timeout') | Statut |
| started_at | TIMESTAMPTZ | Début de la session |
| ended_at | TIMESTAMPTZ | Fin de la session |
| summary | TEXT | Résumé généré par le LLM à la clôture |
| extracted_facts | JSONB | Faits extraits de la conversation |
| message_count | INTEGER | Nombre de messages |
| total_tokens | INTEGER | Tokens LLM consommés |
| created_at | TIMESTAMPTZ | Date de création |
### Message
Un message dans une conversation (message utilisateur ou réponse de Ti-Pote).
| Colonne | Type | Description |
|---------|------|-------------|
| id | UUID (PK) | Identifiant unique |
| session_id | UUID (FK → ConversationSession) | Session parente |
| role | ENUM('user', 'assistant', 'system', 'tool') | Rôle dans la conversation |
| content | TEXT | Contenu textuel du message |
| tool_calls | JSONB | Si le LLM a appelé des functions |
| tool_result | JSONB | Si c'est la réponse d'un tool call |
| audio_url | VARCHAR(500) | URL du fichier audio (optionnel, pour replay) |
| tokens_used | INTEGER | Tokens consommés pour ce message |
| created_at | TIMESTAMPTZ | Timestamp du message |
### MemoryEntry
Un souvenir extrait d'une conversation ou ajouté manuellement. C'est la mémoire épisodique et sémantique.
| Colonne | Type | Description |
|---------|------|-------------|
| id | UUID (PK) | Identifiant unique |
| user_id | UUID (FK → User) | Utilisateur concerné |
| session_id | UUID (FK → ConversationSession, nullable) | Session source (si extrait d'une conversation) |
| type | ENUM('fact', 'preference', 'episode', 'profile') | Type de souvenir |
| content | TEXT | Contenu du souvenir en langage naturel |
| tags | TEXT[] | Tags pour le filtrage rapide |
| importance | FLOAT | Score d'importance (0-1), utilisé pour le ranking |
| is_active | BOOLEAN DEFAULT true | Soft delete (l'utilisateur peut "oublier") |
| created_at | TIMESTAMPTZ | Date de création |
| updated_at | TIMESTAMPTZ | Dernière modification |
### MemoryEmbedding
Vecteur d'embedding associé à un souvenir, pour la recherche sémantique.
| Colonne | Type | Description |
|---------|------|-------------|
| id | UUID (PK) | Identifiant unique |
| memory_id | UUID (FK → MemoryEntry) | Souvenir associé |
| user_id | UUID (FK → User) | Pour l'indexation rapide |
| embedding | VECTOR(1536) | Vecteur d'embedding |
| created_at | TIMESTAMPTZ | Date de création |
Index : `USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)` — optimisé pour la recherche par similarité cosinus.
### Timer
Minuteurs et alarmes.
| Colonne | Type | Description |
|---------|------|-------------|
| id | UUID (PK) | Identifiant unique |
| user_id | UUID (FK → User) | Propriétaire |
| device_id | UUID (FK → Device) | Robot qui doit sonner |
| type | ENUM('timer', 'alarm') | Minuteur ou alarme |
| label | VARCHAR(200) | Description ("Pâtes", "Réveil") |
| trigger_at | TIMESTAMPTZ | Quand déclencher |
| recurrence | VARCHAR(50) | Règle de récurrence (cron-like, nullable) |
| status | ENUM('active', 'triggered', 'cancelled') | Statut |
| created_at | TIMESTAMPTZ | Date de création |
## Données en Redis (non persistées en SQL)
### Session active (clé : `session:{session_id}`)
Historique des messages de la conversation en cours. TTL configurable (par défaut 3 minutes de silence, voir [memory-system.md](memory-system.md)). Structure : liste ordonnée de messages JSON.
### Statut device (clé : `device:{device_id}:status`)
État en temps réel du robot (connecté, en écoute, en train de parler…). TTL de 60 secondes, rafraîchi par heartbeat.
### Timer actif (clé : `timer:{timer_id}`)
Duplication du timer en Redis avec TTL pour la notification via keyspace events. PostgreSQL est la source de vérité (persistance, récurrence), Redis sert uniquement de trigger temps réel.
## Migrations
On utilise un outil de migration TypeScript compatible avec NestJS. Options recommandées : TypeORM Migrations, Prisma Migrate, ou Knex Migrations. Le choix sera fait au moment de l'implémentation en fonction de l'ORM retenu.
Chaque migration est versionnée et réversible. Pas de modification directe du schéma en production.

273
docs/features.md Normal file
View File

@ -0,0 +1,273 @@
# Fonctionnalités
## Vue d'ensemble
Ce document détaille l'ensemble des fonctionnalités prévues pour Ti-Pote, organisées par domaine. Chaque feature inclut une description, les cas d'usage principaux, et les dépendances techniques.
---
## 1. Conversation vocale
### Description
Interaction vocale bidirectionnelle fluide avec Ti-Pote. L'utilisateur parle naturellement, Ti-Pote comprend et répond avec une voix synthétique de qualité.
### Cas d'usage
- L'utilisateur dit "Hey Ti-Pote, quel temps fait-il demain ?" → Ti-Pote répond vocalement avec la météo.
- Conversation à plusieurs tours : "Rappelle-moi ce rendez-vous" → "Lequel ?" → "Celui avec Marie" → "Ton rendez-vous avec Marie est jeudi à 14h."
- L'utilisateur interrompt Ti-Pote pendant qu'il parle → Ti-Pote s'arrête et écoute (barge-in).
### Composants techniques
- **Wake word** : Détection locale sur le robot via OpenWakeWord ou Porcupine. Mot par défaut : "Hey Ti-Pote". Configurable via l'app.
- **VAD (Voice Activity Detection)** : Détection de fin de parole pour savoir quand l'utilisateur a fini de parler. Silero VAD ou WebRTC VAD.
- **STT** : Transcription via Deepgram (streaming, faible latence) ou Whisper (fallback).
- **LLM** : Modèle configurable (GPT-4, Claude, Mistral…). Le system prompt définit la personnalité de Ti-Pote.
- **TTS** : Synthèse vocale via ElevenLabs (qualité), Azure TTS, ou Google TTS. Streaming pour réduire la latence perçue.
### Latence cible
Moins de 1.5 secondes entre la fin de la phrase de l'utilisateur et le début de la réponse audio de Ti-Pote.
---
## 2. Function calling (actions concrètes)
### Description
Ti-Pote peut exécuter des actions en réponse aux demandes de l'utilisateur. Le LLM décide quelle function appeler en fonction du contexte de la conversation.
### Architecture du function calling
Le core maintient un registre de tools (functions) disponibles. Chaque tool est défini avec un nom, une description, et un schéma de paramètres (JSON Schema). Le LLM reçoit ces définitions dans son contexte et peut décider d'en appeler une.
```typescript
// Exemple de définition de tool
const calendarTools = [
{
name: 'create_calendar_event',
description: 'Crée un événement dans le calendrier de l\'utilisateur',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Titre de l\'événement' },
start: { type: 'string', description: 'Date/heure de début (ISO 8601)' },
end: { type: 'string', description: 'Date/heure de fin (ISO 8601)' },
description: { type: 'string', description: 'Description optionnelle' },
},
required: ['title', 'start', 'end'],
},
},
];
```
Le flux complet : l'utilisateur demande quelque chose → STT transcrit → le LLM analyse et retourne un function call → le core exécute la function → le résultat est renvoyé au LLM → le LLM formule une réponse naturelle → TTS → audio.
---
## 3. Agenda / Calendrier
### Description
Gestion complète du calendrier via la voix. Ti-Pote peut créer, modifier, supprimer et consulter des événements.
### Cas d'usage
- "Mets un rendez-vous demain à 15h avec le dentiste"
- "Qu'est-ce que j'ai de prévu cette semaine ?"
- "Décale mon rendez-vous de jeudi à vendredi"
- "Supprime le rendez-vous avec Pierre"
- "Quand est-ce que je suis libre mardi après-midi ?"
### Intégrations
- **Google Calendar** (MVP) — via OAuth2 et l'API Google Calendar v3
- **Apple Calendar** (futur) — via CalDAV
- **Outlook / O365** (futur) — via Microsoft Graph API
### Functions exposées au LLM
- `create_calendar_event` — Créer un événement
- `list_calendar_events` — Lister les événements sur une période
- `update_calendar_event` — Modifier un événement existant
- `delete_calendar_event` — Supprimer un événement
- `find_free_slots` — Trouver des créneaux libres
---
## 4. Emails et messagerie
### Description
Envoi et consultation d'emails par la voix. À terme, intégration avec WhatsApp et d'autres messageries.
### Cas d'usage
- "Envoie un email à Marie pour confirmer le dîner de samedi"
- "Lis-moi mes emails non lus"
- "Réponds à l'email de Pierre en disant que je suis d'accord"
- "Envoie un WhatsApp à Maman pour dire que j'arrive dans 20 minutes"
### Intégrations
- **SMTP/IMAP** (MVP) — Envoi et lecture d'emails
- **Gmail API** (MVP) — Alternative à IMAP, plus riche
- **WhatsApp Business API** (futur) — Envoi de messages
- **Telegram Bot API** (futur) — Alternative plus simple à WhatsApp
### Functions exposées au LLM
- `send_email` — Envoyer un email
- `list_emails` — Lister les emails récents / non lus
- `read_email` — Lire le contenu d'un email spécifique
- `reply_email` — Répondre à un email
- `send_message` — Envoyer un message (WhatsApp, Telegram…)
---
## 5. Minuteurs et alarmes
### Description
Gestion de minuteurs (countdown) et d'alarmes (heure fixe) par la voix. Le robot peut jouer un son ou annoncer vocalement l'expiration.
### Cas d'usage
- "Mets un minuteur de 10 minutes pour les pâtes"
- "Réveille-moi à 7h demain matin"
- "Combien de temps reste sur mon minuteur ?"
- "Annule le minuteur"
- "Mets une alarme tous les jours à 8h"
### Implémentation
Les timers utilisent Redis avec des TTL et les keyspace notifications pour détecter l'expiration. Les alarmes récurrentes sont gérées via un scheduler (cron-like) dans le core.
### Functions exposées au LLM
- `set_timer` — Créer un minuteur (durée)
- `set_alarm` — Créer une alarme (heure fixe, optionnellement récurrente)
- `list_timers` — Lister les timers/alarmes actifs
- `cancel_timer` — Annuler un timer ou une alarme
- `get_timer_remaining` — Temps restant sur un timer
---
## 6. Recherche web
### Description
Ti-Pote peut chercher des informations sur internet à la demande de l'utilisateur et résumer les résultats.
### Cas d'usage
- "Cherche la recette de la tarte tatin"
- "C'est quoi le score du match d'hier ?"
- "Trouve-moi un vol Paris-Lisbonne pour le weekend prochain"
- "Quelle est la capitale du Burkina Faso ?"
### Implémentation
Utilisation d'une API de recherche (SearXNG auto-hébergé pour la privacy, ou Brave Search API, ou Google Custom Search). Le core récupère les résultats, peut extraire le contenu d'une page si nécessaire, et envoie un résumé au LLM pour formulation de la réponse.
### Functions exposées au LLM
- `web_search` — Effectuer une recherche
- `read_webpage` — Extraire le contenu d'une URL spécifique
- `get_weather` — Météo (API dédiée, type OpenWeatherMap)
- `get_news` — Actualités récentes
---
## 7. Reconnaissance visuelle
### Description
Ti-Pote peut "voir" via sa caméra et répondre à des questions sur ce qu'il observe. Le traitement est fait côté cloud (le robot streame l'image, le core analyse).
### Cas d'usage
- "Qu'est-ce que tu vois devant toi ?"
- "Est-ce que tu reconnais cette personne ?" (reconnaissance faciale optionnelle)
- "Lis ce qui est écrit sur ce papier" (OCR)
- "C'est quoi cette plante ?"
### Implémentation
Le robot capture une image (ou un court flux vidéo) et l'envoie au core. Le core utilise un modèle de vision (GPT-4 Vision, Claude Vision, ou un modèle dédié) pour analyser l'image. Pour la reconnaissance faciale, un modèle spécifique type face-api.js ou un service cloud.
### Functions exposées au LLM
- `capture_image` — Demander au robot de prendre une photo
- `analyze_image` — Analyser une image avec un modèle de vision
- `recognize_face` — Identifier une personne connue
- `read_text_from_image` — OCR sur une image
---
## 8. Mobilité (module base mobile)
### Description
Si le robot est équipé du module de base mobile, il peut se déplacer dans l'espace. Commandes vocales simples pour le diriger.
### Cas d'usage
- "Viens ici"
- "Va dans la cuisine"
- "Tourne-toi vers moi"
- "Suis-moi"
### Implémentation
Le core envoie des commandes de mouvement au robot via WebSocket. Le robot exécute localement les commandes moteur. La navigation avancée (cartographie, évitement d'obstacles) nécessiterait du traitement local plus poussé — à voir en phase ultérieure.
### Functions exposées au LLM
- `move_robot` — Déplacer le robot (direction, distance)
- `rotate_robot` — Tourner le robot
- `stop_robot` — Arrêter tout mouvement
- `go_to_base` — Retourner à la station de charge
---
## 9. Notifications proactives
### Description
Ti-Pote ne se contente pas de répondre — il peut initier des interactions pour alerter l'utilisateur.
### Cas d'usage
- Rappel d'un rendez-vous 15 minutes avant
- Alerte météo (pluie prévue, prends ton parapluie)
- Résumé du matin (agenda du jour, météo, emails importants)
- Notification d'un email urgent
- Timer expiré
### Implémentation
Un scheduler dans le core vérifie périodiquement les événements à venir, les conditions météo, les emails entrants, etc. Quand une notification est pertinente, il l'envoie au robot via WebSocket. Le robot joue un son d'attention puis annonce vocalement la notification.
L'utilisateur peut configurer dans l'app quelles notifications il veut recevoir et à quels moments (ne pas déranger la nuit, par exemple).
---
## 10. Mémoire conversationnelle
### Description
Ti-Pote se souvient des conversations passées et des préférences de l'utilisateur. La relation devient plus naturelle avec le temps.
Voir [memory-system.md](memory-system.md) pour l'architecture technique complète.
### Cas d'usage
- "Tu te souviens du restaurant dont on avait parlé ?" → Ti-Pote retrouve la conversation.
- "Commande la même pizza que la dernière fois" → Ti-Pote sait que c'était une quatre fromages.
- Ti-Pote sait que l'utilisateur prend son café à 8h et peut proposer de lancer la machine.
---
## 11. Interface de configuration (App Web / Mobile)
### Description
Une application web (et mobile à terme) pour configurer Ti-Pote et gérer son compte.
### Fonctionnalités de l'app
- **Gestion du compte** : inscription, connexion, profil
- **Gestion des robots** : ajouter/supprimer un robot, nommer, configurer
- **Connexion des services** : lier Google Calendar, compte mail, WhatsApp…
- **Configuration IA** : choix du modèle LLM, de la voix TTS, du provider STT
- **Wake word** : choisir le mot d'activation (liste prédéfinie pour le MVP, custom plus tard)
- **Mémoire** : consulter ce que Ti-Pote sait, éditer ou supprimer des souvenirs
- **Notifications** : configurer les alertes, le mode ne pas déranger
- **Historique** : consulter les conversations passées
- **Modules** : activer/désactiver les modules physiques (caméra, base mobile…)
### Stack
- React / Next.js pour le web
- React Native (ou équivalent) pour le mobile à terme
- Servi par le core NestJS ou déployé séparément en statique
---
## 12. Mode offline dégradé
### Description
Si le robot perd la connexion internet ou que le core est injoignable, certaines fonctionnalités de base restent disponibles localement.
### Fonctionnalités offline
- Timers et alarmes (gérés localement)
- Heure et date
- Commandes de base pré-enregistrées
- Feedback audio (sons, bips) pour confirmer les actions
### Implémentation
Le firmware du robot embarque un petit ensemble de commandes reconnues localement (via un modèle de reconnaissance de commandes léger type TensorFlow Lite). Pas de conversation complète, juste des commandes utilitaires.

207
docs/hardware.md Normal file
View File

@ -0,0 +1,207 @@
# Propositions Hardware
## Philosophie
Le robot Ti-Pote est un **client léger**. Il ne fait aucun traitement lourd d'IA. Son rôle est de capter l'environnement (son, image), d'envoyer les données au cloud, et de restituer les réponses (audio, écran, mouvement). Le choix du hardware est guidé par ce principe : fiable, basse consommation, suffisant pour le streaming et le wake word local.
## Configuration de base (desk robot)
### Carte principale : Raspberry Pi 5 (4 Go)
| Spec | Détail |
|------|--------|
| CPU | Broadcom BCM2712, quad-core Cortex-A76 @ 2.4GHz |
| RAM | 4 Go LPDDR4X |
| Connectivité | Wi-Fi 802.11ac dual-band, Bluetooth 5.0, Gigabit Ethernet |
| GPIO | 40 pins pour les modules (moteurs, LEDs, etc.) |
| Prix | ~60€ |
Pourquoi le Pi 5 : suffisamment puissant pour gérer le streaming audio/vidéo WebSocket, le wake word local (OpenWakeWord), et les animations LED, tout en restant abordable et bien documenté. La communauté est immense, ce qui facilite le debug.
Alternative : Raspberry Pi 4 (4 Go) — moins cher (~45€), suffisant si on n'a pas besoin du surplus de puissance du Pi 5.
### Microphone : ReSpeaker 2-Mics Pi HAT ou ReSpeaker 4-Mic Array
**Option A — ReSpeaker 2-Mics Pi HAT (~12€)**
- 2 microphones, suffisant pour un robot de bureau
- Se branche directement sur le header GPIO du Pi
- Inclut des LEDs RGB programmables (utile pour le feedback visuel)
- Simple à installer (driver I2S)
**Option B — ReSpeaker 4-Mic Array (~30€)**
- 4 microphones en array circulaire
- Meilleure captation directionnelle (beamforming)
- Meilleure suppression du bruit ambiant
- Recommandé si le robot est dans un environnement bruyant
**Recommandation** : commencer avec le 2-Mics pour le MVP. Passer au 4-Mic si la qualité audio est insuffisante.
### Haut-parleur
**Option A — Mini haut-parleur 3W (~5€)**
- Petit, s'intègre dans le boîtier imprimé 3D
- Qualité suffisante pour la parole
- Connecté via le DAC du ReSpeaker ou un ampli I2S (MAX98357A)
**Option B — Haut-parleur 5W avec amplificateur (~15€)**
- Meilleur volume et qualité audio
- Nécessite un petit ampli (MAX98357A breakout board)
- Recommandé pour un usage dans une pièce plus grande
### Caméra (module optionnel)
**Raspberry Pi Camera Module 3 (~30€)**
- 12 MP, autofocus
- Connecteur CSI direct sur le Pi
- Bon pour la reconnaissance visuelle et l'OCR
- Faible latence de capture
Alternative : Raspberry Pi Camera Module 3 Wide (~35€) — champ de vision plus large (120°), utile si le robot doit "voir" une grande partie de la pièce.
### Écran (module optionnel)
**Option A — Écran OLED 1.3" SH1106 (~8€)**
- Petit écran pour afficher des émotions (yeux), l'heure, des icônes de statut
- Interface I2C, très simple à contrôler
- Basse consommation
- Parfait pour un robot de bureau compact
**Option B — Écran LCD 3.5" ILI9486 (~20€)**
- Plus grand, peut afficher du texte, des images, des animations
- Interface SPI
- Permet d'afficher la météo, le prochain rendez-vous, etc.
**Option C — Écran tactile 5" HDMI (~40€)**
- Écran complet avec tactile
- Connecté en HDMI, fonctionne comme un display Linux
- Peut afficher l'interface web de Ti-Pote directement
- Le plus polyvalent mais aussi le plus encombrant et énergivore
### Alimentation
**Alimentation secteur (configuration desk)**
- Alimentation USB-C 5V 5A officielle Raspberry Pi (~15€)
- Le robot desk est branché en permanence
**Module batterie (optionnel, pour la mobilité)**
- PiSugar 3 (~40€) — batterie intégrée pour Raspberry Pi, avec circuit de charge
- UPS HAT avec 18650 (~25€) — plus de capacité, rechargeable
- Autonomie estimée : 2-4h selon l'usage
## Module base mobile (optionnel)
Pour rendre Ti-Pote mobile, il faut ajouter :
### Moteurs et châssis
- 2x moteurs DC avec encodeur (~15€ le kit) — pour la traction différentielle
- Driver moteur L298N ou DRV8833 (~5€)
- Roue omnidirectionnelle arrière (bille) pour la stabilité
- Le châssis est imprimé en 3D (design par Juliann)
### Contrôleur moteur
Option recommandée : une carte Arduino Nano ou ESP32 dédiée au contrôle moteur, qui communique avec le Pi via série (UART). Avantage : le Pi envoie des commandes haut niveau ("avance 30cm", "tourne 90°") et l'Arduino gère le PID et les encodeurs en temps réel.
```
┌─────────────┐ UART/I2C ┌──────────────┐
│ Raspberry Pi │ ◄────────────► │ Arduino Nano │
│ (cerveau) │ │ (moteurs) │
│ │ │ - PID control│
│ │ │ - Encodeurs │
│ │ │ - Capteurs │
└─────────────┘ └──────────────┘
```
### Capteurs de navigation (optionnels)
- Capteurs ultrason HC-SR04 (~3€ x3) — détection d'obstacles basique
- Capteur infrarouge TCRT5000 (~2€ x2) — détection de bord de table
- IMU MPU6050 (~5€) — accéléromètre + gyroscope pour l'orientation
## LEDs et feedback visuel
Les LEDs sont essentielles pour communiquer l'état du robot sans audio :
- **Écoute** : LED bleue pulsante (comme Alexa)
- **Réflexion** : LED jaune clignotante
- **Parle** : LED verte
- **Erreur** : LED rouge
- **Veille** : LED blanche très faible
Options : LEDs RGB intégrées au ReSpeaker, ou un anneau NeoPixel WS2812B (~8€) pour plus de flexibilité et d'effets visuels.
## BOM estimé (Bill of Materials)
### Configuration minimale (desk, voix uniquement)
| Composant | Prix estimé |
|-----------|------------|
| Raspberry Pi 5 (4Go) | 60€ |
| ReSpeaker 2-Mics HAT | 12€ |
| Mini haut-parleur 3W | 5€ |
| Carte microSD 32Go | 8€ |
| Alimentation USB-C 5V 5A | 15€ |
| Filament PLA (boîtier) | ~5€ |
| Visserie, câbles | ~5€ |
| **Total** | **~110€** |
### Configuration complète (desk, voix + caméra + écran)
| Composant | Prix estimé |
|-----------|------------|
| Configuration minimale | 110€ |
| Pi Camera Module 3 | 30€ |
| Écran OLED 1.3" | 8€ |
| NeoPixel Ring (12 LEDs) | 8€ |
| **Total** | **~156€** |
### Configuration mobile
| Composant | Prix estimé |
|-----------|------------|
| Configuration complète | 156€ |
| 2x moteurs DC + encodeurs | 15€ |
| Driver moteur DRV8833 | 5€ |
| Arduino Nano | 8€ |
| Batterie PiSugar 3 | 40€ |
| Capteurs ultrason x3 | 9€ |
| **Total** | **~233€** |
## Wake word — détail technique
Le wake word est la seule tâche d'IA qui tourne localement sur le robot. Deux options :
### OpenWakeWord (recommandé pour le MVP)
- Open source, gratuit
- Tourne sur CPU (Pi 5 sans problème)
- Modèles pré-entraînés disponibles
- Possibilité d'entraîner un modèle custom pour "Hey Ti-Pote"
- Latence : <200ms
- Python, intégrable facilement
### Porcupine (Picovoice)
- Solution commerciale, plan gratuit limité
- Très optimisé, ultra basse latence (<100ms)
- Console web pour créer des wake words custom
- SDK disponible en Python et C
- Payant si plus de 3 devices en production
**Recommandation** : OpenWakeWord pour le MVP (gratuit, suffisant). Entraîner un modèle custom "Hey Ti-Pote" avec leur outil de fine-tuning. Garder Porcupine comme alternative si la précision d'OpenWakeWord est insuffisante.
## Firmware du robot
Le firmware du robot tourne sur le Pi et gère :
1. **Connexion WebSocket** permanente avec le core
2. **Wake word detection** en continu (OpenWakeWord)
3. **Capture et streaming audio** (via ALSA/PulseAudio + le micro)
4. **Playback audio** (réception et lecture des chunks TTS)
5. **Capture image** (à la demande, via la caméra)
6. **Contrôle des LEDs** (feedback visuel de l'état)
7. **Communication avec l'Arduino** (si module mobile, via UART)
8. **Mode offline dégradé** (timers locaux, commandes basiques)
Le firmware sera écrit en **Python** (pour la compatibilité avec OpenWakeWord et les librairies audio du Pi) ou en **TypeScript/Node.js** (pour la cohérence avec le reste du projet). À discuter avec Juliann en fonction de ses préférences côté hardware.

242
docs/infrastructure.md Normal file
View File

@ -0,0 +1,242 @@
# Infrastructure
## Vue d'ensemble du déploiement
```
┌─────────────────────────────────────────────────────────────┐
│ VPS Personnel │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ NestJS │ │ PostgreSQL │ │ Redis │ │
│ │ Core │ │ + pgvector │ │ │ │
│ │ (Docker) │ │ (Docker) │ │ (Docker) │ │
│ └──────┬───────┘ └──────────────┘ └──────────────┘ │
│ │ │
│ ┌──────┴───────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Nginx │ │ Certbot │ │ Prometheus │ │
│ │ (Reverse │ │ (SSL/TLS) │ │ + Grafana │ │
│ │ Proxy) │ │ │ │ (Monitoring)│ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ Docker Compose orchestre l'ensemble │
└─────────────────────────────────────────────────────────────┘
│ HTTPS / WSS
┌──────────┐ ┌──────────────┐
│ Robots │ │ App Web / │
│ Ti-Pote │ │ Mobile │
└──────────┘ └──────────────┘
```
## Composants d'infrastructure
### VPS
Recommandation pour commencer : un VPS avec 4 vCPU, 8 Go RAM, 80 Go SSD. Suffisant pour un usage personnel avec quelques robots connectés. Providers à considérer : Hetzner (bon rapport qualité/prix en Europe), OVH, ou Scaleway.
Quand le projet grandira, on pourra passer sur un setup avec plusieurs containers ou migrer vers du Kubernetes, mais pour le MVP un seul VPS suffit largement.
### Docker Compose
Tout le stack tourne dans Docker Compose. Avantages : déploiement reproductible, isolation des services, facilité de mise à jour.
```yaml
# docker-compose.yml (structure simplifiée)
version: '3.8'
services:
core:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://tipote:${DB_PASSWORD}@postgres:5432/tipote
- REDIS_URL=redis://redis:6379
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- LLM_API_KEY=${LLM_API_KEY}
- STT_API_KEY=${STT_API_KEY}
- TTS_API_KEY=${TTS_API_KEY}
depends_on:
- postgres
- redis
restart: unless-stopped
postgres:
image: pgvector/pgvector:pg16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_DB=tipote
- POSTGRES_USER=tipote
- POSTGRES_PASSWORD=${DB_PASSWORD}
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redisdata:/data
command: redis-server --appendonly yes
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- certbot-etc:/etc/letsencrypt
depends_on:
- core
restart: unless-stopped
volumes:
pgdata:
redisdata:
certbot-etc:
```
### Nginx (Reverse Proxy)
Nginx sert de point d'entrée unique. Il gère le TLS (via Let's Encrypt / Certbot), le routing entre REST et WebSocket, et le rate limiting.
Configuration clé : le WebSocket nécessite les headers `Upgrade` et `Connection` pour fonctionner à travers le proxy.
```nginx
# Extrait de config nginx pertinent
upstream core {
server core:3000;
}
server {
listen 443 ssl;
server_name tipote.example.com;
ssl_certificate /etc/letsencrypt/live/tipote.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tipote.example.com/privkey.pem;
# REST API
location /api/ {
proxy_pass http://core;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket
location /ws/ {
proxy_pass http://core;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400; # 24h — connexion persistante
}
# Frontend (SPA)
location / {
proxy_pass http://core;
}
}
```
### PostgreSQL + pgvector
PostgreSQL 16 avec l'extension pgvector pour le stockage des embeddings (mémoire sémantique).
Stratégie de backup : pg_dump quotidien automatisé + envoi vers un stockage externe (S3 ou équivalent). Un cron job sur le VPS suffit pour le MVP.
```sql
-- Activation de pgvector
CREATE EXTENSION IF NOT EXISTS vector;
-- Exemple : table pour les embeddings de mémoire
CREATE TABLE memory_embeddings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
content TEXT NOT NULL,
embedding vector(1536), -- dimension OpenAI ada-002
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON memory_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
```
### Redis
Utilisé pour trois choses :
1. **Sessions de conversation actives** — Historique des messages en cours, avec un TTL de 30 minutes d'inactivité.
2. **Timers et alarmes** — Stockés avec un TTL correspondant au délai. Redis déclenche un événement à l'expiration via les keyspace notifications.
3. **Cache** — Résultats de recherche web, réponses fréquentes, pour réduire la latence et les coûts API.
### Monitoring (Prometheus + Grafana)
À mettre en place dès le MVP pour suivre :
- Latence de bout en bout (audio in → audio out)
- Taux d'erreur par service externe (STT, TTS, LLM)
- Utilisation des ressources (CPU, RAM, disque)
- Nombre de conversations actives
- Coût estimé en tokens LLM par jour
## Sécurité
### TLS partout
Toutes les communications (robot → core, app → core) passent par TLS. Le WebSocket utilise WSS. Let's Encrypt fournit les certificats, renouvelés automatiquement par Certbot.
### Gestion des secrets
Les secrets sont gérés à plusieurs niveaux :
**Variables d'environnement du VPS** — Les clés API (LLM, STT, TTS) et la clé de chiffrement maître sont stockées dans un fichier `.env` sur le VPS, jamais dans le code. Le fichier `.env` a des permissions restrictives (600, root only).
**Chiffrement des credentials utilisateur** — Les tokens OAuth (Google, Apple) et mots de passe SMTP des utilisateurs sont chiffrés en AES-256-GCM avant d'être stockés en base PostgreSQL. La clé de chiffrement est la variable d'environnement `ENCRYPTION_KEY`.
```typescript
// Exemple simplifié du module de chiffrement
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
export function encrypt(plaintext: string, key: Buffer): string {
const iv = randomBytes(16);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
// Stocke IV + authTag + données chiffrées, encodés en base64
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}
export function decrypt(encryptedBase64: string, key: Buffer): string {
const data = Buffer.from(encryptedBase64, 'base64');
const iv = data.subarray(0, 16);
const authTag = data.subarray(16, 32);
const encrypted = data.subarray(32);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return decipher.update(encrypted) + decipher.final('utf8');
}
```
**Évolution future** — Quand le projet aura plus d'utilisateurs, migration vers HashiCorp Vault ou un équivalent cloud pour la rotation automatique des clés et l'audit trail.
### Authentification
- **Robot → Core** : authentification par JWT (device token) émis lors de l'enregistrement du device. Token rafraîchi périodiquement.
- **App → Core** : authentification classique (email/password ou OAuth2). JWT access token (courte durée) + refresh token (longue durée).
- **Core → Services tiers** : OAuth2 avec stockage chiffré des refresh tokens. Le core rafraîchit les access tokens automatiquement.
## CI/CD
Pour le MVP, un pipeline simple :
1. Push sur `main` → GitHub Actions lance les tests
2. Si les tests passent → build de l'image Docker
3. Push de l'image vers un registry (GitHub Container Registry)
4. Déploiement sur le VPS via SSH + docker compose pull + redémarrage
À terme, on pourra ajouter des environnements de staging, du blue-green deployment, etc.

206
docs/memory-system.md Normal file
View File

@ -0,0 +1,206 @@
# Système de mémoire conversationnelle
## Pourquoi une mémoire ?
Un LLM n'a aucune mémoire native. À chaque requête, il reçoit un bloc de texte (la context window) et répond uniquement sur la base de ce bloc. Si une information n'est pas dans la window, elle n'existe pas pour lui.
La "mémoire conversationnelle" de Ti-Pote, c'est un système que nous construisons pour sélectionner et injecter les bonnes informations dans le contexte du LLM à chaque échange. L'objectif : que Ti-Pote donne l'impression de se souvenir, de connaître l'utilisateur, et de maintenir une relation qui s'enrichit avec le temps.
## Les 3 niveaux de mémoire
```
┌─────────────────────────────────────────────────────────────┐
│ CONTEXT WINDOW DU LLM │
│ │
│ ┌──────────────┐ │
│ │ System Prompt │ Personnalité, instructions, tools │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ Mémoire │ Profil utilisateur + faits connus │
│ │ Sémantique │ (long terme, depuis PostgreSQL) │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ Mémoire │ Résumés de conversations passées │
│ │ Épisodique │ pertinentes (moyen terme, via pgvector) │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ Mémoire de │ Messages de la conversation en cours │
│ │ Session │ (court terme, depuis Redis) │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ Message │ Ce que l'utilisateur vient de dire │
│ │ Courant │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Niveau 1 — Mémoire de session (court terme)
C'est le contexte de la conversation en cours. Quand l'utilisateur dit "mets un rendez-vous demain" puis "ajoute aussi Marie", le "aussi" fait référence au rendez-vous qu'on vient de mentionner.
**Stockage** : Redis (clé `session:{id}`, liste ordonnée de messages).
**Durée de vie** : La session expire après un timeout configurable (par défaut 3 minutes de silence). L'utilisateur peut aussi terminer explicitement ("Merci Ti-Pote", "C'est bon").
**Contenu** : Chaque message inclut le rôle (user/assistant/tool), le contenu textuel, et les éventuels tool calls et résultats.
**Gestion de la taille** : Si l'historique de session dépasse le budget de tokens alloué, les messages les plus anciens sont résumés en un bloc compact par le LLM, et seuls les N derniers messages sont gardés verbatim.
### Niveau 2 — Mémoire épisodique (moyen terme)
C'est le souvenir des conversations passées. "Ce matin tu m'as dit que mon colis arrivait aujourd'hui" ou "La semaine dernière on avait parlé de ce restaurant."
**Stockage** : PostgreSQL (tables `ConversationSession` et `MemoryEntry`).
**Cycle de vie** :
1. La session de conversation se termine (timeout ou fin explicite).
2. Un worker asynchrone prend l'historique complet de la session.
3. Le worker envoie l'historique au LLM avec un prompt d'extraction :
```
Analyse cette conversation et extrais :
1. Un résumé en 2-3 phrases
2. Les faits importants mentionnés (décisions, événements, informations personnelles)
3. Les actions réalisées (rendez-vous créés, emails envoyés, etc.)
4. Les préférences ou habitudes révélées par l'utilisateur
Retourne le résultat en JSON structuré.
```
4. Le worker stocke le résumé dans `ConversationSession.summary` et les faits dans `MemoryEntry`.
5. Le worker génère des embeddings pour chaque fait/résumé et les stocke dans `MemoryEmbedding`.
**Récupération** : Quand une nouvelle conversation commence, le ContextBuilder fait une recherche sémantique (similarity search) dans pgvector avec le message de l'utilisateur comme requête. Les souvenirs les plus pertinents sont injectés dans le contexte.
### Niveau 3 — Mémoire sémantique (long terme)
C'est la connaissance accumulée sur l'utilisateur au fil du temps. "L'utilisateur préfère le café au thé", "Il travaille chez Acme Corp", "Sa femme s'appelle Sophie".
**Stockage** : PostgreSQL (`MemoryEntry` avec type = 'profile' ou 'preference').
**Construction** : Le même worker qui traite la mémoire épisodique met à jour le profil utilisateur. Si le LLM extrait un nouveau fait de type "préférence" ou "info personnelle", il est ajouté ou mis à jour dans le profil.
**Structure du profil** : Le profil est un ensemble de `MemoryEntry` de type 'profile', chacun représentant un fait sur l'utilisateur. Exemples :
- "L'utilisateur s'appelle Arthur" (type: profile)
- "Il préfère la pizza quatre fromages" (type: preference)
- "Son rendez-vous dentiste est le 15 de chaque mois" (type: fact)
- "Il travaille sur le projet Ti-Pote" (type: profile)
**Déduplication et mise à jour** : Si un nouveau fait contredit un ancien ("En fait je préfère la margherita maintenant"), le worker doit identifier l'ancien fait et le mettre à jour plutôt que d'en créer un nouveau. On utilise la similarité sémantique pour détecter les doublons.
## Le ContextBuilder en détail
Le ContextBuilder est le service central qui assemble le prompt pour chaque requête LLM.
### Flux d'exécution
```
Message utilisateur ("Rappelle-moi ce qu'on avait dit sur mon déménagement")
┌──────────────────────────────────────────────┐
│ ContextBuilder │
│ │
│ 1. Charger le system prompt │
│ 2. Charger le profil utilisateur (PG) │
│ 3. Recherche sémantique avec le message │
│ courant comme requête (pgvector) │
│ → top K souvenirs pertinents │
│ 4. Charger l'historique de session (Redis) │
│ 5. Charger les définitions de tools │
│ 6. Assembler le tout en respectant │
│ le budget de tokens │
└──────────────────────────────────────────────┘
Prompt complet envoyé au LLM
```
### Budget de tokens
La context window a une taille limitée (128K tokens pour GPT-4 Turbo, 200K pour Claude). On ne veut pas la remplir entièrement (coût + bruit). Budget recommandé :
| Bloc | Budget (% de la window) | Priorité |
|------|------------------------|----------|
| System prompt + tools | ~15% | Fixe, toujours inclus |
| Message courant | ~5% | Fixe, toujours inclus |
| Historique de session | ~30% | Haute — tronqué si nécessaire |
| Profil utilisateur | ~10% | Haute — résumé si trop long |
| Souvenirs pertinents | ~20% | Moyenne — top K par pertinence |
| Marge pour la réponse | ~20% | Réservée |
Si le budget est dépassé, le ContextBuilder applique les règles de priorité : il réduit d'abord les souvenirs (prend moins de résultats), puis résume l'historique de session, puis tronque le profil.
### Recherche sémantique
La recherche sémantique transforme le message de l'utilisateur en vecteur et cherche les vecteurs les plus proches dans la base d'embeddings.
```typescript
// Pseudo-code de la recherche sémantique
async function findRelevantMemories(
userId: string,
query: string,
topK: number = 5,
): Promise<MemoryEntry[]> {
// 1. Générer l'embedding du message courant
const queryEmbedding = await embeddingService.embed(query);
// 2. Recherche par similarité cosinus dans pgvector
const results = await db.query(`
SELECT me.*, 1 - (emb.embedding <=> $1) AS similarity
FROM memory_entries me
JOIN memory_embeddings emb ON emb.memory_id = me.id
WHERE me.user_id = $2 AND me.is_active = true
ORDER BY emb.embedding <=> $1
LIMIT $3
`, [queryEmbedding, userId, topK]);
// 3. Filtrer par seuil de similarité minimum
return results.filter(r => r.similarity > 0.7);
}
```
L'opérateur `<=>` de pgvector calcule la distance cosinus. Plus la distance est faible (similarité haute), plus le souvenir est pertinent par rapport à la requête.
## Droit à l'oubli
L'utilisateur doit pouvoir contrôler sa mémoire :
- **Via la voix** : "Ti-Pote, oublie ce que je t'ai dit sur X" → le core marque les MemoryEntry concernés comme `is_active = false`.
- **Via l'app** : interface de consultation et suppression des souvenirs. L'utilisateur voit une liste de ce que Ti-Pote "sait" et peut supprimer individuellement ou faire un reset complet.
- **Reset complet** : supprime toutes les MemoryEntry et MemoryEmbedding de l'utilisateur. L'historique des sessions est conservé (pour audit) mais les résumés et faits extraits sont effacés.
Le LLM a aussi un tool dédié :
```typescript
{
name: 'forget_memory',
description: 'Oublie un souvenir spécifique à la demande de l\'utilisateur',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Description de ce que l\'utilisateur veut oublier'
},
scope: {
type: 'string',
enum: ['specific', 'topic', 'all'],
description: 'Oublier un fait précis, tout un sujet, ou tout'
}
},
required: ['query', 'scope']
}
}
```
## Coût et optimisation
La mémoire a un coût en tokens LLM (pour l'extraction à chaque fin de session) et en API d'embeddings. Optimisations prévues :
- **Batch les embeddings** : ne pas générer un embedding par fait, mais regrouper les faits d'une session en un seul appel.
- **Cache les embeddings de requête** : si la même requête revient souvent, ne pas la ré-embedder.
- **Pruning périodique** : un job mensuel qui supprime les souvenirs anciens avec un score d'importance faible et jamais rappelés.
- **Modèle d'embedding léger** : utiliser un modèle d'embedding moins cher que le modèle de conversation (ex: `text-embedding-3-small` d'OpenAI ou un modèle open source local).

227
docs/roadmap.md Normal file
View File

@ -0,0 +1,227 @@
# Roadmap
## Vue d'ensemble
Le développement de Ti-Pote est découpé en phases progressives. Chaque phase produit un livrable fonctionnel et testable. L'objectif est de valider chaque brique avant d'empiler la suivante.
```
Phase 0 Phase 1 Phase 2 Phase 3 Phase 4
Setup & Conversation Services Intelligence Expansion
Infra vocale métier avancée
─────────────────────────────────────────────────────────────────────────────────►
[2-3 sem] [3-4 sem] [4-6 sem] [4-6 sem] [Continu]
```
---
## Phase 0 — Setup & Infrastructure (2-3 semaines)
### Objectif
Mettre en place tout l'environnement de développement et l'infrastructure de base pour que l'équipe puisse coder efficacement.
### Livrables
**Infra & DevOps**
- Repo Git initialisé avec la structure du projet (monorepo ou multi-repo — à décider)
- Docker Compose fonctionnel avec PostgreSQL + pgvector + Redis
- CI/CD basique (GitHub Actions : lint, tests, build)
- VPS provisionné avec Docker, Nginx, Certbot (TLS)
- Domaine configuré (ex: api.tipote.dev)
**Backend**
- Projet NestJS initialisé avec la structure hexagonale
- Configuration de base (env vars, logger, config module)
- Connexion PostgreSQL (TypeORM ou Prisma — à choisir)
- Connexion Redis
- Migrations initiales (tables User, Device, Home)
- Authentification basique (JWT, inscription/connexion)
**Frontend**
- Projet Next.js initialisé
- Page de login/inscription
- Dashboard vide (skeleton)
**Hardware** (Juliann)
- Raspberry Pi 5 configuré (OS, réseau, dépendances)
- Micro + haut-parleur fonctionnels (test audio basique)
- Script de test : enregistrer un audio et le rejouer
### Critère de validation
Un utilisateur peut s'inscrire, se connecter via l'app web, et le Pi est opérationnel avec audio fonctionnel.
---
## Phase 1 — Conversation vocale (3-4 semaines)
### Objectif
Réaliser le premier aller-retour vocal complet : l'utilisateur parle au robot, Ti-Pote comprend et répond vocalement.
### Livrables
**Robot (firmware)**
- Wake word detection avec OpenWakeWord ("Hey Ti-Pote")
- Streaming audio vers le core via WebSocket
- VAD (Voice Activity Detection) pour détecter la fin de parole
- Réception et lecture des chunks audio TTS
- Feedback LED (écoute / réflexion / parole)
**Backend**
- WebSocket Gateway NestJS pour la connexion robot
- Intégration STT (Deepgram en streaming)
- Intégration LLM (OpenAI GPT-4 ou Claude) avec system prompt
- Intégration TTS (ElevenLabs en streaming)
- ConversationService : orchestration du flux complet
- Mémoire de session (Redis) — historique de la conversation en cours
**Frontend**
- Page de configuration du device (associer un robot au compte)
- Indicateur de statut du robot (online/offline)
### Critère de validation
L'utilisateur dit "Hey Ti-Pote, raconte-moi une blague" → le robot répond vocalement avec une blague. Conversation à plusieurs tours fonctionnelle. Latence < 2 secondes.
---
## Phase 2 — Services métier & Function calling (4-6 semaines)
### Objectif
Ajouter les premiers services concrets que Ti-Pote peut appeler, et implémenter le function calling custom.
### Livrables
**Function calling**
- Système de définition et registre des tools
- Logique d'exécution des function calls dans le ConversationService
- Gestion des erreurs de function call (retry, feedback utilisateur)
**Agenda / Calendrier**
- Intégration OAuth2 Google Calendar
- Functions : create/list/update/delete events, find free slots
- Flux complet : "Mets un RDV demain à 15h" → création de l'événement → confirmation vocale
**Minuteurs & Alarmes**
- TimerAlarmService avec stockage Redis + PostgreSQL
- Notification via WebSocket quand un timer expire
- Functions : set/cancel/list timers et alarmes
**Emails**
- Intégration SMTP (envoi) + Gmail API (lecture)
- Functions : send/list/read/reply emails
- Flux complet : "Envoie un email à Pierre" → rédaction par le LLM → envoi → confirmation
**Recherche web**
- Intégration API de recherche (SearXNG ou Brave Search)
- Function : web_search, get_weather
- Flux complet : "Quel temps fait-il demain ?" → recherche → réponse vocale
**Frontend**
- Page de connexion des services (OAuth Google, config SMTP)
- Configuration du modèle LLM et de la voix TTS
- Configuration du wake word (liste prédéfinie)
### Critère de validation
L'utilisateur peut, par la voix : créer un rendez-vous, mettre un minuteur, envoyer un email, poser une question factuelle. Chaque action est confirmée vocalement.
---
## Phase 3 — Intelligence avancée (4-6 semaines)
### Objectif
Ajouter la mémoire conversationnelle, les notifications proactives, et la reconnaissance visuelle.
### Livrables
**Mémoire conversationnelle**
- Worker d'extraction de faits à la fin de chaque session
- Génération d'embeddings et stockage pgvector
- ContextBuilder avec recherche sémantique
- Profil utilisateur auto-enrichi
- Interface dans l'app pour consulter/supprimer les souvenirs
- Droit à l'oubli via la voix ("Oublie ce que je t'ai dit sur X")
**Notifications proactives**
- Scheduler de vérification (rendez-vous, météo, emails)
- Système de notification push via WebSocket
- Configuration des notifications dans l'app (types, horaires, DND)
- Résumé matinal configurable
**Reconnaissance visuelle (si module caméra)**
- Capture d'image à la demande
- Envoi au LLM vision (GPT-4V ou Claude Vision)
- Functions : capture_image, analyze_image
- OCR basique (lire du texte sur une image)
**Frontend**
- Page de mémoire (ce que Ti-Pote sait sur moi)
- Historique des conversations avec replay
- Configuration des notifications
- Dashboard enrichi (activité, stats d'usage)
### Critère de validation
Ti-Pote se souvient des conversations précédentes. "Tu te souviens du restaurant ?" → réponse pertinente. Notifications proactives fonctionnelles (rappel de RDV). Reconnaissance d'image basique ("Qu'est-ce que tu vois ?").
---
## Phase 4 — Expansion (continu)
### Objectif
Améliorer, étendre et polish le produit. Cette phase est continue et n'a pas de fin définie.
### Chantiers prévus
**Intégrations supplémentaires**
- Apple Calendar (CalDAV)
- Microsoft Outlook (Graph API)
- WhatsApp Business API
- Telegram Bot API
- Spotify / services de musique
- Domotique (Home Assistant, Philips Hue…)
**Mobilité**
- Firmware pour le module base mobile
- Communication Pi ↔ Arduino pour le contrôle moteur
- Commandes vocales de déplacement
- Navigation basique (évitement d'obstacles)
**Multi-utilisateur**
- Reconnaissance vocale par utilisateur (voice fingerprint)
- Gestion des permissions par Home
- Profils et mémoire séparés par utilisateur
**App mobile**
- React Native (ou Expo)
- Push notifications sur le téléphone
- Configuration et monitoring du robot à distance
**Améliorations UX**
- Wake word personnalisable (fine-tuning de modèle)
- Personnalité configurable de Ti-Pote
- Animations et expressions sur l'écran du robot
- Mode enfant (contenu filtré, voix adaptée)
**Sécurité et scalabilité**
- Migration vers HashiCorp Vault pour les secrets
- Rate limiting et quotas par utilisateur
- Logs d'audit
- Monitoring avancé (alerting, anomaly detection)
---
## Jalons clés
| Jalon | Phase | Description |
|-------|-------|-------------|
| "Hello World" vocal | Phase 1 | Premier aller-retour audio complet |
| Premier function call | Phase 2 | Ti-Pote crée un événement dans Google Calendar |
| "Tu te souviens ?" | Phase 3 | Ti-Pote retrouve un souvenir d'une conversation passée |
| Notification proactive | Phase 3 | Ti-Pote rappelle un RDV sans qu'on lui demande |
| Robot mobile | Phase 4 | Ti-Pote se déplace dans la pièce |
| Multi-user | Phase 4 | Plusieurs personnes utilisent Ti-Pote dans un foyer |
## Principes de développement
1. **Itérer vite** — Chaque phase produit quelque chose de testable. Pas de "big bang release".
2. **Tester en conditions réelles** — Dès la Phase 1, le robot doit être utilisé quotidiennement pour découvrir les vrais problèmes (latence, bruit, faux positifs du wake word…).
3. **Mesurer** — Logger la latence, le taux de succès STT, les erreurs de function call. Les métriques guident les priorités.
4. **Documenter au fur et à mesure** — Pas de "on documentera plus tard". Chaque feature est documentée à sa livraison.
5. **Software d'abord** — La plupart des features peuvent être testées sans le robot physique (via un client WebSocket de test, un micro de PC, etc.). Ne pas bloquer le dev software sur le hardware.

13
package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "ti-pote",
"version": "0.0.1",
"private": true,
"description": "Robot animatronique de bureau personnel — modulaire, imprimé en 3D, propulsé par l'IA",
"scripts": {
"dev": "pnpm --filter @ti-pote/backend dev",
"build": "pnpm --filter @ti-pote/backend build",
"start": "pnpm --filter @ti-pote/backend start:prod",
"lint": "pnpm -r lint",
"format": "pnpm -r format"
}
}

6699
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- 'apps/*'
- 'packages/*'

25
tsconfig.base.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"strict": true
}
}