Migration Gitea - sauvegarde locale 2025-12-04 18:58

This commit is contained in:
StillHammer 2025-12-04 18:58:32 +08:00
parent cb938500cd
commit ce2b25a599
46 changed files with 8704 additions and 8704 deletions

6
.gitmodules vendored
View File

@ -1,3 +1,3 @@
[submodule "external/whisper.cpp"] [submodule "external/whisper.cpp"]
path = external/whisper.cpp path = external/whisper.cpp
url = https://github.com/ggerganov/whisper.cpp url = https://github.com/ggerganov/whisper.cpp

File diff suppressed because it is too large Load Diff

View File

@ -1,340 +1,340 @@
# AUDIT DE CONFORMITÉ GROVEENGINE - AISSIA # AUDIT DE CONFORMITÉ GROVEENGINE - AISSIA
**Date** : 2025-11-26 **Date** : 2025-11-26
**Auditeur** : Claude Code **Auditeur** : Claude Code
**Version auditée** : Commit bc3b6cb **Version auditée** : Commit bc3b6cb
--- ---
## RÉSUMÉ EXÉCUTIF ## RÉSUMÉ EXÉCUTIF
**Verdict : Le code contourne massivement les principes de GroveEngine.** **Verdict : Le code contourne massivement les principes de GroveEngine.**
| Module | Lignes | Conformité Engine | Statut | | Module | Lignes | Conformité Engine | Statut |
|--------|--------|-------------------|--------| |--------|--------|-------------------|--------|
| AIModule | 306 | VIOLATION | Infrastructure dans module | | AIModule | 306 | VIOLATION | Infrastructure dans module |
| MonitoringModule | 222 | VIOLATION | Appels OS dans module | | MonitoringModule | 222 | VIOLATION | Appels OS dans module |
| StorageModule | 273 | VIOLATION | SQLite dans module | | StorageModule | 273 | VIOLATION | SQLite dans module |
| VoiceModule | 209 | VIOLATION | TTS/COM dans module | | VoiceModule | 209 | VIOLATION | TTS/COM dans module |
| SchedulerModule | 179 | CONFORME | Logique métier pure | | SchedulerModule | 179 | CONFORME | Logique métier pure |
| NotificationModule | 172 | CONFORME | Logique métier pure | | NotificationModule | 172 | CONFORME | Logique métier pure |
**Score global** : 2/6 modules conformes (33%) **Score global** : 2/6 modules conformes (33%)
--- ---
## RAPPEL DES PRINCIPES GROVEENGINE ## RAPPEL DES PRINCIPES GROVEENGINE
Selon `docs/GROVEENGINE_GUIDE.md` : Selon `docs/GROVEENGINE_GUIDE.md` :
1. **Modules = Pure business logic** (200-300 lignes recommandées) 1. **Modules = Pure business logic** (200-300 lignes recommandées)
2. **No infrastructure code in modules** : threading, networking, persistence 2. **No infrastructure code in modules** : threading, networking, persistence
3. **All data via IDataNode abstraction** (backend agnostic) 3. **All data via IDataNode abstraction** (backend agnostic)
4. **Pull-based message processing** via IIO pub/sub 4. **Pull-based message processing** via IIO pub/sub
5. **Hot-reload ready** : sérialiser tout l'état dans `getState()` 5. **Hot-reload ready** : sérialiser tout l'état dans `getState()`
--- ---
## VIOLATIONS CRITIQUES ## VIOLATIONS CRITIQUES
### 1. AIModule - Networking dans le module ### 1. AIModule - Networking dans le module
**Fichier** : `src/modules/AIModule.cpp:146` **Fichier** : `src/modules/AIModule.cpp:146`
```cpp ```cpp
nlohmann::json AIModule::agenticLoop(const std::string& userQuery) { nlohmann::json AIModule::agenticLoop(const std::string& userQuery) {
// ... // ...
auto response = m_provider->chat(m_systemPrompt, messages, tools); auto response = m_provider->chat(m_systemPrompt, messages, tools);
// Appel HTTP synchrone bloquant ! // Appel HTTP synchrone bloquant !
} }
``` ```
**Violation** : Appels HTTP synchrones directement dans `process()` via la boucle agentique. **Violation** : Appels HTTP synchrones directement dans `process()` via la boucle agentique.
**Impact** : **Impact** :
- Bloque la boucle principale pendant chaque requête LLM (timeout 60s) - Bloque la boucle principale pendant chaque requête LLM (timeout 60s)
- `isIdle()` retourne false pendant l'appel, mais le module reste bloquant - `isIdle()` retourne false pendant l'appel, mais le module reste bloquant
- Hot-reload impossible pendant une requête en cours - Hot-reload impossible pendant une requête en cours
- Tous les autres modules sont bloqués - Tous les autres modules sont bloqués
**Correction requise** : Déléguer les appels LLM à un service infrastructure externe, communication via IIO async. **Correction requise** : Déléguer les appels LLM à un service infrastructure externe, communication via IIO async.
--- ---
### 2. StorageModule - Persistence dans le module ### 2. StorageModule - Persistence dans le module
**Fichier** : `src/modules/StorageModule.cpp:78-91` **Fichier** : `src/modules/StorageModule.cpp:78-91`
```cpp ```cpp
bool StorageModule::openDatabase() { bool StorageModule::openDatabase() {
int rc = sqlite3_open(m_dbPath.c_str(), &m_db); int rc = sqlite3_open(m_dbPath.c_str(), &m_db);
// Handle SQLite directement dans le module // Handle SQLite directement dans le module
} }
``` ```
**Violation** : Gestion directe de SQLite. L'engine préconise `IDataNode` abstractions pour la persistence. **Violation** : Gestion directe de SQLite. L'engine préconise `IDataNode` abstractions pour la persistence.
**Impact** : **Impact** :
- Hot-reload risqué (handle DB ouvert) - Hot-reload risqué (handle DB ouvert)
- Risque de corruption si reload pendant transaction - Risque de corruption si reload pendant transaction
- Couplage fort avec SQLite - Couplage fort avec SQLite
**Correction requise** : Service StorageService dans main.cpp, modules communiquent via topics `storage:*`. **Correction requise** : Service StorageService dans main.cpp, modules communiquent via topics `storage:*`.
--- ---
### 3. MonitoringModule - Appels OS dans le module ### 3. MonitoringModule - Appels OS dans le module
**Fichier** : `src/modules/MonitoringModule.cpp:78-79` **Fichier** : `src/modules/MonitoringModule.cpp:78-79`
```cpp ```cpp
void MonitoringModule::checkCurrentApp(float currentTime) { void MonitoringModule::checkCurrentApp(float currentTime) {
std::string newApp = m_tracker->getCurrentAppName(); std::string newApp = m_tracker->getCurrentAppName();
// Appelle GetForegroundWindow(), OpenProcess(), etc. // Appelle GetForegroundWindow(), OpenProcess(), etc.
} }
``` ```
**Violation** : Appels Win32 API dans `process()`. Même encapsulé dans `IWindowTracker`, c'est du code plateforme dans un module hot-reloadable. **Violation** : Appels Win32 API dans `process()`. Même encapsulé dans `IWindowTracker`, c'est du code plateforme dans un module hot-reloadable.
**Impact** : **Impact** :
- Dépendance plateforme dans le module - Dépendance plateforme dans le module
- Handles système potentiellement orphelins au reload - Handles système potentiellement orphelins au reload
**Correction requise** : Service PlatformService qui publie `monitoring:window_info` périodiquement. **Correction requise** : Service PlatformService qui publie `monitoring:window_info` périodiquement.
--- ---
### 4. VoiceModule - COM/SAPI dans le module ### 4. VoiceModule - COM/SAPI dans le module
**Fichier** : `src/modules/VoiceModule.cpp:122` **Fichier** : `src/modules/VoiceModule.cpp:122`
```cpp ```cpp
void VoiceModule::speak(const std::string& text) { void VoiceModule::speak(const std::string& text) {
m_ttsEngine->speak(text, true); m_ttsEngine->speak(text, true);
// Appel ISpVoice::Speak via COM // Appel ISpVoice::Speak via COM
} }
``` ```
**Fichier** : `src/shared/audio/SAPITTSEngine.hpp:26` **Fichier** : `src/shared/audio/SAPITTSEngine.hpp:26`
```cpp ```cpp
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
``` ```
**Violation** : Initialisation COM et appels SAPI dans le module. **Violation** : Initialisation COM et appels SAPI dans le module.
**Impact** : **Impact** :
- `CoInitializeEx` par thread, hot-reload peut causer des fuites - `CoInitializeEx` par thread, hot-reload peut causer des fuites
- Appels asynchrones SAPI difficiles à gérer au shutdown - Appels asynchrones SAPI difficiles à gérer au shutdown
**Correction requise** : Service VoiceService dédié, modules envoient `voice:speak`. **Correction requise** : Service VoiceService dédié, modules envoient `voice:speak`.
--- ---
## PROBLÈMES DE DESIGN ## PROBLÈMES DE DESIGN
### 5. Topics incohérents ### 5. Topics incohérents
**SchedulerModule.h:26** utilise le format slash : **SchedulerModule.h:26** utilise le format slash :
```cpp ```cpp
// "scheduler/hyperfocus_alert" // "scheduler/hyperfocus_alert"
``` ```
**AIModule.cpp:52** utilise le format colon : **AIModule.cpp:52** utilise le format colon :
```cpp ```cpp
m_io->subscribe("scheduler:hyperfocus_alert", subConfig); m_io->subscribe("scheduler:hyperfocus_alert", subConfig);
``` ```
**Standard GroveEngine** : Format `module:event` (colon) **Standard GroveEngine** : Format `module:event` (colon)
**Impact** : Les messages ne seront jamais reçus si les formats ne correspondent pas. **Impact** : Les messages ne seront jamais reçus si les formats ne correspondent pas.
--- ---
### 6. SchedulerModule - IIO non utilisé ### 6. SchedulerModule - IIO non utilisé
**Fichier** : `src/modules/SchedulerModule.cpp:66-68` **Fichier** : `src/modules/SchedulerModule.cpp:66-68`
```cpp ```cpp
void SchedulerModule::checkHyperfocus(float currentTime) { void SchedulerModule::checkHyperfocus(float currentTime) {
// ... // ...
// Publier l'alerte (si IO disponible) // Publier l'alerte (si IO disponible)
// Note: Dans une version complète, on publierait via m_io // Note: Dans une version complète, on publierait via m_io
} }
``` ```
**Problème** : Le SchedulerModule a `m_io` mais ne publie JAMAIS rien. Les autres modules s'abonnent à `scheduler:*` mais ne recevront rien. **Problème** : Le SchedulerModule a `m_io` mais ne publie JAMAIS rien. Les autres modules s'abonnent à `scheduler:*` mais ne recevront rien.
--- ---
### 7. État non restaurable - StorageModule ### 7. État non restaurable - StorageModule
**Fichier** : `src/modules/StorageModule.cpp:246-258` **Fichier** : `src/modules/StorageModule.cpp:246-258`
```cpp ```cpp
std::unique_ptr<grove::IDataNode> StorageModule::getState() { std::unique_ptr<grove::IDataNode> StorageModule::getState() {
state->setBool("isConnected", m_isConnected); state->setBool("isConnected", m_isConnected);
// ... // ...
} }
void StorageModule::setState(const grove::IDataNode& state) { void StorageModule::setState(const grove::IDataNode& state) {
// NE ROUVRE PAS la connexion DB ! // NE ROUVRE PAS la connexion DB !
m_logger->info("Etat restore..."); m_logger->info("Etat restore...");
} }
``` ```
**Problème** : `setState()` ne restaure pas la connexion SQLite. Après hot-reload, le module est dans un état incohérent. **Problème** : `setState()` ne restaure pas la connexion SQLite. Après hot-reload, le module est dans un état incohérent.
--- ---
### 8. Libraries statiques dans modules ### 8. Libraries statiques dans modules
**CMakeLists.txt:86-101** : **CMakeLists.txt:86-101** :
```cmake ```cmake
add_library(AissiaLLM STATIC ...) add_library(AissiaLLM STATIC ...)
target_link_libraries(AIModule PRIVATE AissiaLLM) target_link_libraries(AIModule PRIVATE AissiaLLM)
``` ```
**Problème** : Les libs `AissiaLLM`, `AissiaPlatform`, `AissiaAudio` sont compilées en STATIC et linkées dans chaque .so. **Problème** : Les libs `AissiaLLM`, `AissiaPlatform`, `AissiaAudio` sont compilées en STATIC et linkées dans chaque .so.
**Impact** : **Impact** :
- Code dupliqué dans chaque module - Code dupliqué dans chaque module
- Hot-reload ne rafraîchit pas ces libs - Hot-reload ne rafraîchit pas ces libs
- Pas de partage d'état entre modules - Pas de partage d'état entre modules
--- ---
### 9. Dépassement limite de lignes ### 9. Dépassement limite de lignes
| Module | Lignes | Limite recommandée | | Module | Lignes | Limite recommandée |
|--------|--------|-------------------| |--------|--------|-------------------|
| AIModule | 306 | 200-300 | | AIModule | 306 | 200-300 |
Le dépassement est mineur mais symptomatique : le module fait trop de choses. Le dépassement est mineur mais symptomatique : le module fait trop de choses.
--- ---
## CE QUI EST CONFORME ## CE QUI EST CONFORME
### SchedulerModule & NotificationModule ### SchedulerModule & NotificationModule
Ces deux modules respectent les principes : Ces deux modules respectent les principes :
- Logique métier pure - Logique métier pure
- Pas d'appels système - Pas d'appels système
- État sérialisable - État sérialisable
- Taille appropriée - Taille appropriée
### Structure IModule ### Structure IModule
Tous les modules implémentent correctement : Tous les modules implémentent correctement :
- `process()` - `process()`
- `setConfiguration()` - `setConfiguration()`
- `getState()` / `setState()` - `getState()` / `setState()`
- `getHealthStatus()` - `getHealthStatus()`
- `shutdown()` - `shutdown()`
- Exports C `createModule()` / `destroyModule()` - Exports C `createModule()` / `destroyModule()`
### main.cpp ### main.cpp
La boucle principale est bien implémentée : La boucle principale est bien implémentée :
- FileWatcher pour hot-reload - FileWatcher pour hot-reload
- Frame timing à 10Hz - Frame timing à 10Hz
- Signal handling propre - Signal handling propre
- Chargement/déchargement correct - Chargement/déchargement correct
--- ---
## ARCHITECTURE RECOMMANDÉE ## ARCHITECTURE RECOMMANDÉE
### Actuelle (INCORRECTE) ### Actuelle (INCORRECTE)
``` ```
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ main.cpp │ │ main.cpp │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │AIModule │ │Storage │ │Monitor │ │Voice │ │ │ │AIModule │ │Storage │ │Monitor │ │Voice │ │
│ │ +HTTP │ │ +SQLite │ │ +Win32 │ │ +COM │ │ │ │ +HTTP │ │ +SQLite │ │ +Win32 │ │ +COM │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
Infrastructure DANS les modules = VIOLATION Infrastructure DANS les modules = VIOLATION
``` ```
### Corrigée (CONFORME) ### Corrigée (CONFORME)
``` ```
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ main.cpp │ │ main.cpp │
│ │ │ │
│ ┌─────────────── INFRASTRUCTURE ──────────────────┐ │ │ ┌─────────────── INFRASTRUCTURE ──────────────────┐ │
│ │ LLMService │ StorageService │ PlatformService │ │ │ │ │ LLMService │ StorageService │ PlatformService │ │ │
│ │ (async) │ (SQLite) │ (Win32) │ │ │ │ │ (async) │ (SQLite) │ (Win32) │ │ │
│ └──────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────┘ │
│ ↑↓ IIO pub/sub (async, non-bloquant) │ │ ↑↓ IIO pub/sub (async, non-bloquant) │
│ ┌─────────────── MODULES (hot-reload) ────────────┐ │ │ ┌─────────────── MODULES (hot-reload) ────────────┐ │
│ │ AIModule │ StorageModule │ MonitoringModule │ │ │ │ │ AIModule │ StorageModule │ MonitoringModule │ │ │
│ │ (logic) │ (logic) │ (logic) │ │ │ │ │ (logic) │ (logic) │ (logic) │ │ │
│ └──────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
Infrastructure HORS modules = CONFORME Infrastructure HORS modules = CONFORME
``` ```
### Flux de données corrigé ### Flux de données corrigé
``` ```
User query → voice:transcription → AIModule User query → voice:transcription → AIModule
AIModule → ai:query_request → LLMService (async) AIModule → ai:query_request → LLMService (async)
LLMService → ai:response → AIModule LLMService → ai:response → AIModule
AIModule → ai:response → VoiceModule → voice:speak AIModule → ai:response → VoiceModule → voice:speak
``` ```
--- ---
## ACTIONS REQUISES ## ACTIONS REQUISES
### Priorité HAUTE ### Priorité HAUTE
1. **Extraire LLM de AIModule** 1. **Extraire LLM de AIModule**
- Créer `LLMService` dans main.cpp ou service dédié - Créer `LLMService` dans main.cpp ou service dédié
- AIModule publie `ai:query_request`, reçoit `ai:response` - AIModule publie `ai:query_request`, reçoit `ai:response`
- Appels HTTP dans thread séparé - Appels HTTP dans thread séparé
2. **Extraire SQLite de StorageModule** 2. **Extraire SQLite de StorageModule**
- Créer `StorageService` - Créer `StorageService`
- Modules publient `storage:save_*`, reçoivent `storage:result` - Modules publient `storage:save_*`, reçoivent `storage:result`
3. **Extraire Win32 de MonitoringModule** 3. **Extraire Win32 de MonitoringModule**
- Créer `PlatformService` - Créer `PlatformService`
- Publie `platform:window_changed` périodiquement - Publie `platform:window_changed` périodiquement
4. **Extraire TTS de VoiceModule** 4. **Extraire TTS de VoiceModule**
- Créer `VoiceService` - Créer `VoiceService`
- Modules publient `voice:speak` - Modules publient `voice:speak`
### Priorité MOYENNE ### Priorité MOYENNE
5. **Corriger format topics** : Tout en `module:event` 5. **Corriger format topics** : Tout en `module:event`
6. **Implémenter publish dans SchedulerModule** 6. **Implémenter publish dans SchedulerModule**
7. **Corriger setState dans StorageModule** 7. **Corriger setState dans StorageModule**
### Priorité BASSE ### Priorité BASSE
8. **Refactorer libs STATIC en services** 8. **Refactorer libs STATIC en services**
9. **Réduire AIModule sous 300 lignes** 9. **Réduire AIModule sous 300 lignes**
--- ---
## CONCLUSION ## CONCLUSION
Le code actuel **simule** l'utilisation de GroveEngine mais le **contourne** en plaçant l'infrastructure directement dans les modules. Le code actuel **simule** l'utilisation de GroveEngine mais le **contourne** en plaçant l'infrastructure directement dans les modules.
Les modules ne sont **pas véritablement hot-reloadable** car ils : Les modules ne sont **pas véritablement hot-reloadable** car ils :
1. Possèdent des ressources système (DB handles, COM objects) 1. Possèdent des ressources système (DB handles, COM objects)
2. Font des appels bloquants (HTTP 60s timeout, TTS) 2. Font des appels bloquants (HTTP 60s timeout, TTS)
3. Ne communiquent pas correctement via IIO 3. Ne communiquent pas correctement via IIO
**Refactoring majeur requis** pour extraire l'infrastructure des modules vers des services dédiés dans main.cpp. **Refactoring majeur requis** pour extraire l'infrastructure des modules vers des services dédiés dans main.cpp.
--- ---
*Audit généré automatiquement par Claude Code* *Audit généré automatiquement par Claude Code*

View File

@ -1,305 +1,305 @@
# AISSIA MCP Configuration for Claude Code # AISSIA MCP Configuration for Claude Code
This directory contains an example MCP (Model Context Protocol) configuration for integrating AISSIA with Claude Code. This directory contains an example MCP (Model Context Protocol) configuration for integrating AISSIA with Claude Code.
## Quick Setup ## Quick Setup
### 1. Locate Claude Code MCP Settings ### 1. Locate Claude Code MCP Settings
The MCP configuration file location depends on your operating system: The MCP configuration file location depends on your operating system:
**Windows**: **Windows**:
``` ```
%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json %APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json
``` ```
Full path example: Full path example:
``` ```
C:\Users\YourUsername\AppData\Roaming\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json C:\Users\YourUsername\AppData\Roaming\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json
``` ```
**macOS**: **macOS**:
``` ```
~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json
``` ```
**Linux**: **Linux**:
``` ```
~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json ~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json
``` ```
### 2. Copy Configuration ### 2. Copy Configuration
Copy the contents of `claude_code_mcp_config.json` to the Claude Code MCP settings file. Copy the contents of `claude_code_mcp_config.json` to the Claude Code MCP settings file.
**Important**: Update the `command` path to point to your actual AISSIA executable: **Important**: Update the `command` path to point to your actual AISSIA executable:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"aissia": { "aissia": {
"command": "C:\\path\\to\\your\\aissia\\build\\aissia.exe", "command": "C:\\path\\to\\your\\aissia\\build\\aissia.exe",
"args": ["--mcp-server"], "args": ["--mcp-server"],
"disabled": false "disabled": false
} }
} }
} }
``` ```
### 3. Restart Claude Code ### 3. Restart Claude Code
Restart VS Code (or reload window: `Ctrl+Shift+P` → "Developer: Reload Window") to apply the changes. Restart VS Code (or reload window: `Ctrl+Shift+P` → "Developer: Reload Window") to apply the changes.
### 4. Verify Integration ### 4. Verify Integration
Open Claude Code and check that AISSIA tools are available: Open Claude Code and check that AISSIA tools are available:
``` ```
You: Can you list the available MCP servers? You: Can you list the available MCP servers?
Claude: I have access to the following MCP servers: Claude: I have access to the following MCP servers:
- aissia: 13 tools available - aissia: 13 tools available
``` ```
## Available Tools ## Available Tools
Once configured, Claude will have access to these 13 AISSIA tools: Once configured, Claude will have access to these 13 AISSIA tools:
### AISSIA Core (5 tools) ### AISSIA Core (5 tools)
1. **chat_with_aissia** ⭐ - Dialogue with AISSIA's AI assistant (Claude Sonnet 4) 1. **chat_with_aissia** ⭐ - Dialogue with AISSIA's AI assistant (Claude Sonnet 4)
2. **transcribe_audio** - Transcribe audio files to text 2. **transcribe_audio** - Transcribe audio files to text
3. **text_to_speech** - Convert text to speech audio files 3. **text_to_speech** - Convert text to speech audio files
4. **save_memory** - Save notes to AISSIA's persistent storage 4. **save_memory** - Save notes to AISSIA's persistent storage
5. **search_memories** - Search through saved memories 5. **search_memories** - Search through saved memories
### File System (8 tools) ### File System (8 tools)
6. **read_file** - Read file contents 6. **read_file** - Read file contents
7. **write_file** - Write content to files 7. **write_file** - Write content to files
8. **list_directory** - List files in a directory 8. **list_directory** - List files in a directory
9. **search_files** - Search for files by pattern 9. **search_files** - Search for files by pattern
10. **file_exists** - Check if a file exists 10. **file_exists** - Check if a file exists
11. **create_directory** - Create directories 11. **create_directory** - Create directories
12. **delete_file** - Delete files 12. **delete_file** - Delete files
13. **move_file** - Move or rename files 13. **move_file** - Move or rename files
## Configuration Options ## Configuration Options
### Basic Configuration ### Basic Configuration
```json ```json
{ {
"mcpServers": { "mcpServers": {
"aissia": { "aissia": {
"command": "path/to/aissia.exe", "command": "path/to/aissia.exe",
"args": ["--mcp-server"], "args": ["--mcp-server"],
"disabled": false "disabled": false
} }
} }
} }
``` ```
### With Auto-Approval ### With Auto-Approval
To skip confirmation prompts for specific tools: To skip confirmation prompts for specific tools:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"aissia": { "aissia": {
"command": "path/to/aissia.exe", "command": "path/to/aissia.exe",
"args": ["--mcp-server"], "args": ["--mcp-server"],
"disabled": false, "disabled": false,
"alwaysAllow": ["chat_with_aissia", "read_file", "write_file"] "alwaysAllow": ["chat_with_aissia", "read_file", "write_file"]
} }
} }
} }
``` ```
### Disable Server ### Disable Server
To temporarily disable AISSIA without removing the configuration: To temporarily disable AISSIA without removing the configuration:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"aissia": { "aissia": {
"command": "path/to/aissia.exe", "command": "path/to/aissia.exe",
"args": ["--mcp-server"], "args": ["--mcp-server"],
"disabled": true // <-- Set to true "disabled": true // <-- Set to true
} }
} }
} }
``` ```
## Prerequisites ## Prerequisites
Before running AISSIA in MCP server mode, ensure these config files exist: Before running AISSIA in MCP server mode, ensure these config files exist:
### config/ai.json ### config/ai.json
```json ```json
{ {
"provider": "claude", "provider": "claude",
"api_key": "sk-ant-api03-...", "api_key": "sk-ant-api03-...",
"model": "claude-sonnet-4-20250514", "model": "claude-sonnet-4-20250514",
"max_iterations": 10, "max_iterations": 10,
"system_prompt": "Tu es AISSIA, un assistant personnel intelligent..." "system_prompt": "Tu es AISSIA, un assistant personnel intelligent..."
} }
``` ```
### config/storage.json ### config/storage.json
```json ```json
{ {
"database_path": "./data/aissia.db", "database_path": "./data/aissia.db",
"journal_mode": "WAL", "journal_mode": "WAL",
"busy_timeout_ms": 5000 "busy_timeout_ms": 5000
} }
``` ```
### config/voice.json (optional) ### config/voice.json (optional)
```json ```json
{ {
"tts": { "tts": {
"enabled": true, "enabled": true,
"rate": 0, "rate": 0,
"volume": 80 "volume": 80
}, },
"stt": { "stt": {
"active_mode": { "active_mode": {
"enabled": false "enabled": false
} }
} }
} }
``` ```
## Testing MCP Server ## Testing MCP Server
You can test the MCP server independently before integrating with Claude Code: You can test the MCP server independently before integrating with Claude Code:
```bash ```bash
# Test tools/list # Test tools/list
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./build/aissia.exe --mcp-server echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./build/aissia.exe --mcp-server
# Test chat_with_aissia tool # Test chat_with_aissia tool
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chat_with_aissia","arguments":{"message":"What time is it?"}}}' | ./build/aissia.exe --mcp-server echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chat_with_aissia","arguments":{"message":"What time is it?"}}}' | ./build/aissia.exe --mcp-server
``` ```
## Troubleshooting ## Troubleshooting
### "Server not found" or "Connection failed" ### "Server not found" or "Connection failed"
1. Verify the `command` path is correct and points to `aissia.exe` 1. Verify the `command` path is correct and points to `aissia.exe`
2. Make sure AISSIA compiles successfully: `cmake --build build` 2. Make sure AISSIA compiles successfully: `cmake --build build`
3. Test running `./build/aissia.exe --mcp-server` manually 3. Test running `./build/aissia.exe --mcp-server` manually
### "LLMService not initialized" ### "LLMService not initialized"
AISSIA requires `config/ai.json` with a valid Claude API key. Check: AISSIA requires `config/ai.json` with a valid Claude API key. Check:
1. File exists: `config/ai.json` 1. File exists: `config/ai.json`
2. API key is valid: `"api_key": "sk-ant-api03-..."` 2. API key is valid: `"api_key": "sk-ant-api03-..."`
3. Provider is set: `"provider": "claude"` 3. Provider is set: `"provider": "claude"`
### "Tool execution failed" ### "Tool execution failed"
Some tools have limited functionality in Phase 8 MVP: Some tools have limited functionality in Phase 8 MVP:
- `transcribe_audio` - Not fully implemented yet (STT file support needed) - `transcribe_audio` - Not fully implemented yet (STT file support needed)
- `text_to_speech` - Not fully implemented yet (TTS file output needed) - `text_to_speech` - Not fully implemented yet (TTS file output needed)
- `save_memory` - Not fully implemented yet (Storage sync methods needed) - `save_memory` - Not fully implemented yet (Storage sync methods needed)
- `search_memories` - Not fully implemented yet (Storage sync methods needed) - `search_memories` - Not fully implemented yet (Storage sync methods needed)
These will be completed in Phase 8.1 and 8.2. These will be completed in Phase 8.1 and 8.2.
### Server starts but tools don't appear ### Server starts but tools don't appear
1. Check Claude Code logs: `Ctrl+Shift+P` → "Developer: Open Extension Logs" 1. Check Claude Code logs: `Ctrl+Shift+P` → "Developer: Open Extension Logs"
2. Look for MCP server initialization errors 2. Look for MCP server initialization errors
3. Verify JSON syntax in the MCP configuration file 3. Verify JSON syntax in the MCP configuration file
## Example Use Cases ## Example Use Cases
### 1. Ask AISSIA for Help ### 1. Ask AISSIA for Help
``` ```
You: Use chat_with_aissia to ask "What are my top productivity patterns?" You: Use chat_with_aissia to ask "What are my top productivity patterns?"
Claude: [calls chat_with_aissia tool] Claude: [calls chat_with_aissia tool]
AISSIA: Based on your activity data, your most productive hours are 9-11 AM... AISSIA: Based on your activity data, your most productive hours are 9-11 AM...
``` ```
### 2. File Operations + AI ### 2. File Operations + AI
``` ```
You: Read my TODO.md file and ask AISSIA to prioritize the tasks You: Read my TODO.md file and ask AISSIA to prioritize the tasks
Claude: [calls read_file("TODO.md")] Claude: [calls read_file("TODO.md")]
Claude: [calls chat_with_aissia with task list] Claude: [calls chat_with_aissia with task list]
AISSIA: Here's a prioritized version based on urgency and dependencies... AISSIA: Here's a prioritized version based on urgency and dependencies...
``` ```
### 3. Voice Transcription (future) ### 3. Voice Transcription (future)
``` ```
You: Transcribe meeting-notes.wav to text You: Transcribe meeting-notes.wav to text
Claude: [calls transcribe_audio("meeting-notes.wav")] Claude: [calls transcribe_audio("meeting-notes.wav")]
Result: "Welcome to the team meeting. Today we're discussing..." Result: "Welcome to the team meeting. Today we're discussing..."
``` ```
## Advanced Configuration ## Advanced Configuration
### Multiple MCP Servers ### Multiple MCP Servers
You can configure multiple MCP servers alongside AISSIA: You can configure multiple MCP servers alongside AISSIA:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"aissia": { "aissia": {
"command": "C:\\path\\to\\aissia\\build\\aissia.exe", "command": "C:\\path\\to\\aissia\\build\\aissia.exe",
"args": ["--mcp-server"], "args": ["--mcp-server"],
"disabled": false "disabled": false
}, },
"filesystem": { "filesystem": {
"command": "npx", "command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "C:\\Users"], "args": ["-y", "@modelcontextprotocol/server-filesystem", "C:\\Users"],
"disabled": false "disabled": false
}, },
"brave-search": { "brave-search": {
"command": "npx", "command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"], "args": ["-y", "@modelcontextprotocol/server-brave-search"],
"disabled": false, "disabled": false,
"env": { "env": {
"BRAVE_API_KEY": "your-brave-api-key" "BRAVE_API_KEY": "your-brave-api-key"
} }
} }
} }
} }
``` ```
### Environment Variables ### Environment Variables
Pass environment variables to AISSIA: Pass environment variables to AISSIA:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"aissia": { "aissia": {
"command": "C:\\path\\to\\aissia\\build\\aissia.exe", "command": "C:\\path\\to\\aissia\\build\\aissia.exe",
"args": ["--mcp-server"], "args": ["--mcp-server"],
"disabled": false, "disabled": false,
"env": { "env": {
"AISSIA_LOG_LEVEL": "debug", "AISSIA_LOG_LEVEL": "debug",
"CLAUDE_API_KEY": "sk-ant-api03-..." "CLAUDE_API_KEY": "sk-ant-api03-..."
} }
} }
} }
} }
``` ```
## References ## References
- **Full Documentation**: `docs/CLAUDE_CODE_INTEGRATION.md` - **Full Documentation**: `docs/CLAUDE_CODE_INTEGRATION.md`
- **MCP Specification**: https://github.com/anthropics/mcp - **MCP Specification**: https://github.com/anthropics/mcp
- **Claude Code Extension**: https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev - **Claude Code Extension**: https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev
## Support ## Support
For issues or questions: For issues or questions:
1. Check the full documentation: `docs/CLAUDE_CODE_INTEGRATION.md` 1. Check the full documentation: `docs/CLAUDE_CODE_INTEGRATION.md`
2. Review logs: AISSIA writes to stderr in MCP mode 2. Review logs: AISSIA writes to stderr in MCP mode
3. Test manually: `./build/aissia.exe --mcp-server` and send JSON-RPC requests 3. Test manually: `./build/aissia.exe --mcp-server` and send JSON-RPC requests

View File

@ -1,10 +1,10 @@
{ {
"mcpServers": { "mcpServers": {
"aissia": { "aissia": {
"command": "C:\\Users\\alexi\\Documents\\projects\\aissia\\build\\aissia.exe", "command": "C:\\Users\\alexi\\Documents\\projects\\aissia\\build\\aissia.exe",
"args": ["--mcp-server"], "args": ["--mcp-server"],
"disabled": false, "disabled": false,
"alwaysAllow": [] "alwaysAllow": []
} }
} }
} }

View File

@ -1,35 +1,35 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate test audio WAV file for STT testing""" """Generate test audio WAV file for STT testing"""
import sys import sys
try: try:
from gtts import gTTS from gtts import gTTS
import os import os
from pydub import AudioSegment from pydub import AudioSegment
# Generate French test audio # Generate French test audio
text = "Bonjour, ceci est un test de reconnaissance vocale." text = "Bonjour, ceci est un test de reconnaissance vocale."
print(f"Generating audio: '{text}'") print(f"Generating audio: '{text}'")
# Create TTS # Create TTS
tts = gTTS(text=text, lang='fr', slow=False) tts = gTTS(text=text, lang='fr', slow=False)
tts.save("test_audio_temp.mp3") tts.save("test_audio_temp.mp3")
print("✓ Generated MP3") print("✓ Generated MP3")
# Convert to WAV (16kHz, mono, 16-bit PCM) # Convert to WAV (16kHz, mono, 16-bit PCM)
audio = AudioSegment.from_mp3("test_audio_temp.mp3") audio = AudioSegment.from_mp3("test_audio_temp.mp3")
audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2) audio = audio.set_frame_rate(16000).set_channels(1).set_sample_width(2)
audio.export("test_audio.wav", format="wav") audio.export("test_audio.wav", format="wav")
print("✓ Converted to WAV (16kHz, mono, 16-bit)") print("✓ Converted to WAV (16kHz, mono, 16-bit)")
# Cleanup # Cleanup
os.remove("test_audio_temp.mp3") os.remove("test_audio_temp.mp3")
print("✓ Saved as test_audio.wav") print("✓ Saved as test_audio.wav")
print(f"Duration: {len(audio)/1000:.1f}s") print(f"Duration: {len(audio)/1000:.1f}s")
except ImportError as e: except ImportError as e:
print(f"Missing dependency: {e}") print(f"Missing dependency: {e}")
print("\nInstall with: pip install gtts pydub") print("\nInstall with: pip install gtts pydub")
print("Note: pydub also requires ffmpeg") print("Note: pydub also requires ffmpeg")
sys.exit(1) sys.exit(1)

View File

@ -1,38 +1,38 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate simple test audio WAV file using only stdlib""" """Generate simple test audio WAV file using only stdlib"""
import wave import wave
import struct import struct
import math import math
# WAV parameters # WAV parameters
sample_rate = 16000 sample_rate = 16000
duration = 2 # seconds duration = 2 # seconds
frequency = 440 # Hz (A4 note) frequency = 440 # Hz (A4 note)
# Generate sine wave samples # Generate sine wave samples
samples = [] samples = []
for i in range(int(sample_rate * duration)): for i in range(int(sample_rate * duration)):
# Sine wave value (-1.0 to 1.0) # Sine wave value (-1.0 to 1.0)
value = math.sin(2.0 * math.pi * frequency * i / sample_rate) value = math.sin(2.0 * math.pi * frequency * i / sample_rate)
# Convert to 16-bit PCM (-32768 to 32767) # Convert to 16-bit PCM (-32768 to 32767)
sample = int(value * 32767) sample = int(value * 32767)
samples.append(sample) samples.append(sample)
# Write WAV file # Write WAV file
with wave.open("test_audio.wav", "w") as wav_file: with wave.open("test_audio.wav", "w") as wav_file:
# Set parameters (1 channel, 2 bytes per sample, 16kHz) # Set parameters (1 channel, 2 bytes per sample, 16kHz)
wav_file.setnchannels(1) wav_file.setnchannels(1)
wav_file.setsampwidth(2) wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate) wav_file.setframerate(sample_rate)
# Write frames # Write frames
for sample in samples: for sample in samples:
wav_file.writeframes(struct.pack('<h', sample)) wav_file.writeframes(struct.pack('<h', sample))
print(f"[OK] Generated test_audio.wav") print(f"[OK] Generated test_audio.wav")
print(f" - Format: 16kHz, mono, 16-bit PCM") print(f" - Format: 16kHz, mono, 16-bit PCM")
print(f" - Duration: {duration}s") print(f" - Duration: {duration}s")
print(f" - Frequency: {frequency}Hz (A4 tone)") print(f" - Frequency: {frequency}Hz (A4 tone)")
print(f" - Samples: {len(samples)}") print(f" - Samples: {len(samples)}")

View File

@ -1,449 +1,449 @@
# AISSIA - Claude Code Integration (Phase 8) # AISSIA - Claude Code Integration (Phase 8)
## Overview ## Overview
AISSIA can now be exposed as an **MCP Server** (Model Context Protocol) to integrate with Claude Code and other MCP-compatible clients. This allows Claude to use AISSIA's capabilities as tools during conversations. AISSIA can now be exposed as an **MCP Server** (Model Context Protocol) to integrate with Claude Code and other MCP-compatible clients. This allows Claude to use AISSIA's capabilities as tools during conversations.
**Mode MCP Server**: `./aissia --mcp-server` **Mode MCP Server**: `./aissia --mcp-server`
This mode exposes AISSIA's services via JSON-RPC 2.0 over stdio, following the MCP specification. This mode exposes AISSIA's services via JSON-RPC 2.0 over stdio, following the MCP specification.
## Available Tools ## Available Tools
AISSIA exposes **13 tools** total: AISSIA exposes **13 tools** total:
### 1. AISSIA Core Tools (Priority) ### 1. AISSIA Core Tools (Priority)
#### `chat_with_aissia` ⭐ **PRIORITY** #### `chat_with_aissia` ⭐ **PRIORITY**
Dialogue with AISSIA's built-in AI assistant (Claude Sonnet 4). Send a message and get an intelligent response with access to AISSIA's knowledge and capabilities. Dialogue with AISSIA's built-in AI assistant (Claude Sonnet 4). Send a message and get an intelligent response with access to AISSIA's knowledge and capabilities.
**Input**: **Input**:
```json ```json
{ {
"message": "string (required) - Message to send to AISSIA", "message": "string (required) - Message to send to AISSIA",
"conversation_id": "string (optional) - Conversation ID for continuity", "conversation_id": "string (optional) - Conversation ID for continuity",
"system_prompt": "string (optional) - Custom system prompt" "system_prompt": "string (optional) - Custom system prompt"
} }
``` ```
**Output**: **Output**:
```json ```json
{ {
"response": "AISSIA's response text", "response": "AISSIA's response text",
"conversation_id": "conversation-id", "conversation_id": "conversation-id",
"tokens": 1234, "tokens": 1234,
"iterations": 2 "iterations": 2
} }
``` ```
**Example use case**: "Hey AISSIA, can you analyze my focus patterns this week?" **Example use case**: "Hey AISSIA, can you analyze my focus patterns this week?"
#### `transcribe_audio` #### `transcribe_audio`
Transcribe audio file to text using Speech-to-Text engines (Whisper.cpp, OpenAI Whisper API, Google Speech). Transcribe audio file to text using Speech-to-Text engines (Whisper.cpp, OpenAI Whisper API, Google Speech).
**Input**: **Input**:
```json ```json
{ {
"file_path": "string (required) - Path to audio file", "file_path": "string (required) - Path to audio file",
"language": "string (optional) - Language code (e.g., 'fr', 'en'). Default: 'fr'" "language": "string (optional) - Language code (e.g., 'fr', 'en'). Default: 'fr'"
} }
``` ```
**Output**: **Output**:
```json ```json
{ {
"text": "Transcribed text from audio", "text": "Transcribed text from audio",
"file": "/path/to/audio.wav", "file": "/path/to/audio.wav",
"language": "fr" "language": "fr"
} }
``` ```
**Status**: ⚠️ Not yet implemented - requires STT service file transcription support **Status**: ⚠️ Not yet implemented - requires STT service file transcription support
#### `text_to_speech` #### `text_to_speech`
Convert text to speech audio file using Text-to-Speech synthesis. Generates audio in WAV format. Convert text to speech audio file using Text-to-Speech synthesis. Generates audio in WAV format.
**Input**: **Input**:
```json ```json
{ {
"text": "string (required) - Text to synthesize", "text": "string (required) - Text to synthesize",
"output_file": "string (required) - Output audio file path (WAV)", "output_file": "string (required) - Output audio file path (WAV)",
"voice": "string (optional) - Voice identifier (e.g., 'fr-fr', 'en-us'). Default: 'fr-fr'" "voice": "string (optional) - Voice identifier (e.g., 'fr-fr', 'en-us'). Default: 'fr-fr'"
} }
``` ```
**Output**: **Output**:
```json ```json
{ {
"success": true, "success": true,
"file": "/path/to/output.wav", "file": "/path/to/output.wav",
"voice": "fr-fr" "voice": "fr-fr"
} }
``` ```
**Status**: ⚠️ Not yet implemented - requires TTS engine file output support **Status**: ⚠️ Not yet implemented - requires TTS engine file output support
#### `save_memory` #### `save_memory`
Save a note or memory to AISSIA's persistent storage. Memories can be tagged and searched later. Save a note or memory to AISSIA's persistent storage. Memories can be tagged and searched later.
**Input**: **Input**:
```json ```json
{ {
"title": "string (required) - Memory title", "title": "string (required) - Memory title",
"content": "string (required) - Memory content", "content": "string (required) - Memory content",
"tags": ["array of strings (optional) - Tags for categorization"] "tags": ["array of strings (optional) - Tags for categorization"]
} }
``` ```
**Output**: **Output**:
```json ```json
{ {
"id": "memory-uuid", "id": "memory-uuid",
"title": "Meeting notes", "title": "Meeting notes",
"timestamp": "2025-01-30T10:00:00Z" "timestamp": "2025-01-30T10:00:00Z"
} }
``` ```
**Status**: ⚠️ Not yet implemented - requires StorageService sync methods **Status**: ⚠️ Not yet implemented - requires StorageService sync methods
#### `search_memories` #### `search_memories`
Search through saved memories and notes in AISSIA's storage. Returns matching memories with relevance scores. Search through saved memories and notes in AISSIA's storage. Returns matching memories with relevance scores.
**Input**: **Input**:
```json ```json
{ {
"query": "string (required) - Search query", "query": "string (required) - Search query",
"limit": "integer (optional) - Maximum results to return. Default: 10" "limit": "integer (optional) - Maximum results to return. Default: 10"
} }
``` ```
**Output**: **Output**:
```json ```json
{ {
"results": [ "results": [
{ {
"id": "memory-uuid", "id": "memory-uuid",
"title": "Meeting notes", "title": "Meeting notes",
"content": "...", "content": "...",
"score": 0.85, "score": 0.85,
"tags": ["work", "meeting"] "tags": ["work", "meeting"]
} }
], ],
"count": 5 "count": 5
} }
``` ```
**Status**: ⚠️ Not yet implemented - requires StorageService sync methods **Status**: ⚠️ Not yet implemented - requires StorageService sync methods
### 2. File System Tools (8 tools) ### 2. File System Tools (8 tools)
- `read_file` - Read a file from the filesystem - `read_file` - Read a file from the filesystem
- `write_file` - Write content to a file - `write_file` - Write content to a file
- `list_directory` - List files in a directory - `list_directory` - List files in a directory
- `search_files` - Search for files by pattern - `search_files` - Search for files by pattern
- `file_exists` - Check if a file exists - `file_exists` - Check if a file exists
- `create_directory` - Create a new directory - `create_directory` - Create a new directory
- `delete_file` - Delete a file - `delete_file` - Delete a file
- `move_file` - Move or rename a file - `move_file` - Move or rename a file
These tools provide Claude with direct filesystem access to work with files on your system. These tools provide Claude with direct filesystem access to work with files on your system.
## Installation for Claude Code ## Installation for Claude Code
### 1. Configure Claude Code MCP ### 1. Configure Claude Code MCP
Create or edit your Claude Code MCP configuration file: Create or edit your Claude Code MCP configuration file:
**Windows**: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json` **Windows**: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json`
**macOS/Linux**: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` **macOS/Linux**: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`
Add AISSIA as an MCP server: Add AISSIA as an MCP server:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"aissia": { "aissia": {
"command": "C:\\path\\to\\aissia\\build\\aissia.exe", "command": "C:\\path\\to\\aissia\\build\\aissia.exe",
"args": ["--mcp-server"], "args": ["--mcp-server"],
"disabled": false "disabled": false
} }
} }
} }
``` ```
**Note**: Replace `C:\\path\\to\\aissia\\build\\aissia.exe` with the actual path to your compiled AISSIA executable. **Note**: Replace `C:\\path\\to\\aissia\\build\\aissia.exe` with the actual path to your compiled AISSIA executable.
### 2. Verify Configuration ### 2. Verify Configuration
Restart Claude Code (or VS Code) to reload the MCP configuration. Restart Claude Code (or VS Code) to reload the MCP configuration.
Claude should now have access to all 13 AISSIA tools during conversations. Claude should now have access to all 13 AISSIA tools during conversations.
### 3. Test Integration ### 3. Test Integration
In Claude Code, try: In Claude Code, try:
``` ```
"Can you use the chat_with_aissia tool to ask AISSIA what time it is?" "Can you use the chat_with_aissia tool to ask AISSIA what time it is?"
``` ```
Claude will call the `chat_with_aissia` tool, which internally uses AISSIA's LLM service to process the query. Claude will call the `chat_with_aissia` tool, which internally uses AISSIA's LLM service to process the query.
## Architecture ## Architecture
### Synchronous Mode (MCP Server) ### Synchronous Mode (MCP Server)
When running as an MCP server, AISSIA uses **synchronous blocking calls** instead of the async pub/sub architecture used in normal mode: When running as an MCP server, AISSIA uses **synchronous blocking calls** instead of the async pub/sub architecture used in normal mode:
```cpp ```cpp
// Normal mode (async) // Normal mode (async)
io->publish("llm:request", data); io->publish("llm:request", data);
// ... wait for response on "llm:response" topic // ... wait for response on "llm:response" topic
// MCP mode (sync) // MCP mode (sync)
auto response = llmService->sendMessageSync(message, conversationId); auto response = llmService->sendMessageSync(message, conversationId);
// immediate result // immediate result
``` ```
This is necessary because: This is necessary because:
1. MCP protocol expects immediate JSON-RPC responses 1. MCP protocol expects immediate JSON-RPC responses
2. No event loop in MCP server mode (stdin/stdout blocking I/O) 2. No event loop in MCP server mode (stdin/stdout blocking I/O)
3. Simplifies integration with external tools 3. Simplifies integration with external tools
### Service Integration ### Service Integration
``` ```
MCPServer (stdio JSON-RPC) MCPServer (stdio JSON-RPC)
MCPServerTools (tool handlers) MCPServerTools (tool handlers)
Services (sync methods) Services (sync methods)
├── LLMService::sendMessageSync() ├── LLMService::sendMessageSync()
├── VoiceService::transcribeFileSync() ├── VoiceService::transcribeFileSync()
├── VoiceService::textToSpeechSync() ├── VoiceService::textToSpeechSync()
└── StorageService (stub implementations) └── StorageService (stub implementations)
``` ```
### Tool Registry ### Tool Registry
All tools are registered in a central `ToolRegistry`: All tools are registered in a central `ToolRegistry`:
```cpp ```cpp
ToolRegistry registry; ToolRegistry registry;
// 1. Internal tools (get_current_time) // 1. Internal tools (get_current_time)
registry.registerTool("get_current_time", ...); registry.registerTool("get_current_time", ...);
// 2. FileSystem tools (8 tools) // 2. FileSystem tools (8 tools)
for (auto& toolDef : FileSystemTools::getToolDefinitions()) { for (auto& toolDef : FileSystemTools::getToolDefinitions()) {
registry.registerTool(toolDef); registry.registerTool(toolDef);
} }
// 3. AISSIA tools (5 tools) // 3. AISSIA tools (5 tools)
MCPServerTools aissiaTools(llmService, storageService, voiceService); MCPServerTools aissiaTools(llmService, storageService, voiceService);
for (const auto& toolDef : aissiaTools.getToolDefinitions()) { for (const auto& toolDef : aissiaTools.getToolDefinitions()) {
registry.registerTool(toolDef); registry.registerTool(toolDef);
} }
``` ```
Total: **13 tools** Total: **13 tools**
## Configuration Files ## Configuration Files
AISSIA MCP Server requires these config files (same as normal mode): AISSIA MCP Server requires these config files (same as normal mode):
- `config/ai.json` - LLM provider configuration (Claude API key) - `config/ai.json` - LLM provider configuration (Claude API key)
- `config/storage.json` - Database path and settings - `config/storage.json` - Database path and settings
- `config/voice.json` - TTS/STT engine settings - `config/voice.json` - TTS/STT engine settings
**Important**: Make sure these files are present before running `--mcp-server` mode. **Important**: Make sure these files are present before running `--mcp-server` mode.
## Limitations (Phase 8 MVP) ## Limitations (Phase 8 MVP)
1. **STT/TTS file operations**: `transcribe_audio` and `text_to_speech` are not fully implemented yet 1. **STT/TTS file operations**: `transcribe_audio` and `text_to_speech` are not fully implemented yet
- STT service needs file transcription support (currently only streaming) - STT service needs file transcription support (currently only streaming)
- TTS engine needs file output support (currently only direct playback) - TTS engine needs file output support (currently only direct playback)
2. **Storage sync methods**: `save_memory` and `search_memories` return "not implemented" errors 2. **Storage sync methods**: `save_memory` and `search_memories` return "not implemented" errors
- StorageService needs `saveMemorySync()` and `searchMemoriesSync()` methods - StorageService needs `saveMemorySync()` and `searchMemoriesSync()` methods
- Current storage only works via async pub/sub - Current storage only works via async pub/sub
3. **No hot-reload**: MCP server mode doesn't load hot-reloadable modules 3. **No hot-reload**: MCP server mode doesn't load hot-reloadable modules
- Only services and tools are available - Only services and tools are available
- No SchedulerModule, MonitoringModule, etc. - No SchedulerModule, MonitoringModule, etc.
4. **Single-threaded**: MCP server runs synchronously on main thread 4. **Single-threaded**: MCP server runs synchronously on main thread
- LLMService worker thread still runs for agentic loops - LLMService worker thread still runs for agentic loops
- But overall server is blocking on stdin - But overall server is blocking on stdin
## Roadmap ## Roadmap
### Phase 8.1 - Complete STT/TTS Sync Methods ### Phase 8.1 - Complete STT/TTS Sync Methods
- [ ] Implement `VoiceService::transcribeFileSync()` using STT engines - [ ] Implement `VoiceService::transcribeFileSync()` using STT engines
- [ ] Implement `VoiceService::textToSpeechSync()` with file output - [ ] Implement `VoiceService::textToSpeechSync()` with file output
- [ ] Test audio file transcription via MCP - [ ] Test audio file transcription via MCP
### Phase 8.2 - Storage Sync Methods ### Phase 8.2 - Storage Sync Methods
- [ ] Implement `StorageService::saveMemorySync()` - [ ] Implement `StorageService::saveMemorySync()`
- [ ] Implement `StorageService::searchMemoriesSync()` - [ ] Implement `StorageService::searchMemoriesSync()`
- [ ] Add vector embeddings for semantic search - [ ] Add vector embeddings for semantic search
### Phase 8.3 - Advanced Tools ### Phase 8.3 - Advanced Tools
- [ ] `schedule_task` - Add tasks to AISSIA's scheduler - [ ] `schedule_task` - Add tasks to AISSIA's scheduler
- [ ] `get_focus_stats` - Retrieve hyperfocus detection stats - [ ] `get_focus_stats` - Retrieve hyperfocus detection stats
- [ ] `list_active_apps` - Get current monitored applications - [ ] `list_active_apps` - Get current monitored applications
- [ ] `send_notification` - Trigger system notifications - [ ] `send_notification` - Trigger system notifications
### Phase 8.4 - Multi-Modal Support ### Phase 8.4 - Multi-Modal Support
- [ ] Image input for LLM (Claude vision) - [ ] Image input for LLM (Claude vision)
- [ ] PDF/document parsing tools - [ ] PDF/document parsing tools
- [ ] Web scraping integration - [ ] Web scraping integration
## Use Cases ## Use Cases
### 1. AI Assistant Collaboration ### 1. AI Assistant Collaboration
Claude Code can delegate complex reasoning tasks to AISSIA: Claude Code can delegate complex reasoning tasks to AISSIA:
``` ```
Claude: "I need to analyze user behavior patterns. Let me ask AISSIA." Claude: "I need to analyze user behavior patterns. Let me ask AISSIA."
→ calls chat_with_aissia("Analyze recent focus patterns") → calls chat_with_aissia("Analyze recent focus patterns")
AISSIA: "Based on monitoring data, user has 3 hyperfocus sessions daily averaging 2.5 hours..." AISSIA: "Based on monitoring data, user has 3 hyperfocus sessions daily averaging 2.5 hours..."
``` ```
### 2. Voice Transcription Workflow ### 2. Voice Transcription Workflow
``` ```
Claude: "Transcribe meeting-2025-01-30.wav" Claude: "Transcribe meeting-2025-01-30.wav"
→ calls transcribe_audio(file_path="meeting-2025-01-30.wav", language="en") → calls transcribe_audio(file_path="meeting-2025-01-30.wav", language="en")
→ calls write_file(path="transcript.txt", content=result) → calls write_file(path="transcript.txt", content=result)
``` ```
### 3. Knowledge Management ### 3. Knowledge Management
``` ```
Claude: "Save this important insight to AISSIA's memory" Claude: "Save this important insight to AISSIA's memory"
→ calls save_memory( → calls save_memory(
title="Project architecture decision", title="Project architecture decision",
content="We decided to use hot-reload modules for business logic...", content="We decided to use hot-reload modules for business logic...",
tags=["architecture", "project"] tags=["architecture", "project"]
) )
``` ```
### 4. File + AI Operations ### 4. File + AI Operations
``` ```
Claude: "Read todos.md, ask AISSIA to prioritize tasks, update file" Claude: "Read todos.md, ask AISSIA to prioritize tasks, update file"
→ calls read_file("todos.md") → calls read_file("todos.md")
→ calls chat_with_aissia("Prioritize these tasks: ...") → calls chat_with_aissia("Prioritize these tasks: ...")
→ calls write_file("todos-prioritized.md", content=...) → calls write_file("todos-prioritized.md", content=...)
``` ```
## Development ## Development
### Adding New Tools ### Adding New Tools
1. **Declare tool in MCPServerTools.hpp**: 1. **Declare tool in MCPServerTools.hpp**:
```cpp ```cpp
json handleNewTool(const json& input); json handleNewTool(const json& input);
``` ```
2. **Implement in MCPServerTools.cpp**: 2. **Implement in MCPServerTools.cpp**:
```cpp ```cpp
json MCPServerTools::handleNewTool(const json& input) { json MCPServerTools::handleNewTool(const json& input) {
// Extract input parameters // Extract input parameters
std::string param = input["param"]; std::string param = input["param"];
// Call service // Call service
auto result = m_someService->doSomethingSync(param); auto result = m_someService->doSomethingSync(param);
// Return JSON result // Return JSON result
return { return {
{"output", result}, {"output", result},
{"status", "success"} {"status", "success"}
}; };
} }
``` ```
3. **Register in getToolDefinitions()**: 3. **Register in getToolDefinitions()**:
```cpp ```cpp
tools.push_back({ tools.push_back({
"new_tool", "new_tool",
"Description of what this tool does", "Description of what this tool does",
{ {
{"type", "object"}, {"type", "object"},
{"properties", { {"properties", {
{"param", { {"param", {
{"type", "string"}, {"type", "string"},
{"description", "Parameter description"} {"description", "Parameter description"}
}} }}
}}, }},
{"required", json::array({"param"})} {"required", json::array({"param"})}
}, },
[this](const json& input) { return handleNewTool(input); } [this](const json& input) { return handleNewTool(input); }
}); });
``` ```
4. **Add to execute() switch**: 4. **Add to execute() switch**:
```cpp ```cpp
if (toolName == "new_tool") { if (toolName == "new_tool") {
return handleNewTool(input); return handleNewTool(input);
} }
``` ```
### Testing MCP Server ### Testing MCP Server
Test with `nc` or `socat`: Test with `nc` or `socat`:
```bash ```bash
# Send tools/list request # Send tools/list request
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./build/aissia.exe --mcp-server echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./build/aissia.exe --mcp-server
# Send tool call # Send tool call
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chat_with_aissia","arguments":{"message":"Hello AISSIA"}}}' | ./build/aissia.exe --mcp-server echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"chat_with_aissia","arguments":{"message":"Hello AISSIA"}}}' | ./build/aissia.exe --mcp-server
``` ```
Expected output format: Expected output format:
```json ```json
{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"chat_with_aissia","description":"...","inputSchema":{...}}]}} {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"chat_with_aissia","description":"...","inputSchema":{...}}]}}
``` ```
## Troubleshooting ## Troubleshooting
### "LLMService not initialized" ### "LLMService not initialized"
Make sure `config/ai.json` exists with valid API key: Make sure `config/ai.json` exists with valid API key:
```json ```json
{ {
"provider": "claude", "provider": "claude",
"api_key": "sk-ant-...", "api_key": "sk-ant-...",
"model": "claude-sonnet-4-20250514" "model": "claude-sonnet-4-20250514"
} }
``` ```
### "VoiceService not available" ### "VoiceService not available"
Voice tools are optional. If you don't need STT/TTS, this is normal. Voice tools are optional. If you don't need STT/TTS, this is normal.
### "StorageService not available" ### "StorageService not available"
Make sure `config/storage.json` exists: Make sure `config/storage.json` exists:
```json ```json
{ {
"database_path": "./data/aissia.db", "database_path": "./data/aissia.db",
"journal_mode": "WAL", "journal_mode": "WAL",
"busy_timeout_ms": 5000 "busy_timeout_ms": 5000
} }
``` ```
### "Tool not found" ### "Tool not found"
Check `tools/list` output to see which tools are actually registered. Check `tools/list` output to see which tools are actually registered.
## References ## References
- **MCP Specification**: https://github.com/anthropics/mcp - **MCP Specification**: https://github.com/anthropics/mcp
- **AISSIA Architecture**: `docs/project-overview.md` - **AISSIA Architecture**: `docs/project-overview.md`
- **GroveEngine Guide**: `docs/GROVEENGINE_GUIDE.md` - **GroveEngine Guide**: `docs/GROVEENGINE_GUIDE.md`
- **LLM Service**: `src/services/LLMService.hpp` - **LLM Service**: `src/services/LLMService.hpp`
- **MCPServer**: `src/shared/mcp/MCPServer.hpp` - **MCPServer**: `src/shared/mcp/MCPServer.hpp`

View File

@ -1,268 +1,268 @@
# Speech-to-Text (STT) Setup Guide - Windows # Speech-to-Text (STT) Setup Guide - Windows
Guide pour configurer les moteurs de reconnaissance vocale STT sur Windows. Guide pour configurer les moteurs de reconnaissance vocale STT sur Windows.
## État Actuel ## État Actuel
AISSIA supporte **5 moteurs STT** avec priorités automatiques : AISSIA supporte **5 moteurs STT** avec priorités automatiques :
| Moteur | Type | Status | Requis | | Moteur | Type | Status | Requis |
|--------|------|--------|--------| |--------|------|--------|--------|
| **Whisper.cpp** | Local | ✅ Configuré | Modèle téléchargé | | **Whisper.cpp** | Local | ✅ Configuré | Modèle téléchargé |
| **OpenAI Whisper API** | Cloud | ✅ Configuré | API key dans .env | | **OpenAI Whisper API** | Cloud | ✅ Configuré | API key dans .env |
| **Google Speech** | Cloud | ✅ Configuré | API key dans .env | | **Google Speech** | Cloud | ✅ Configuré | API key dans .env |
| **Azure STT** | Cloud | ⚠️ Optionnel | API key manquante | | **Azure STT** | Cloud | ⚠️ Optionnel | API key manquante |
| **Deepgram** | Cloud | ⚠️ Optionnel | API key manquante | | **Deepgram** | Cloud | ⚠️ Optionnel | API key manquante |
**3 moteurs sont déjà fonctionnels** (Whisper.cpp, OpenAI, Google) ✅ **3 moteurs sont déjà fonctionnels** (Whisper.cpp, OpenAI, Google) ✅
--- ---
## 1. Whisper.cpp (Local, Offline) ✅ ## 1. Whisper.cpp (Local, Offline) ✅
### Avantages ### Avantages
- ✅ Complètement offline (pas d'internet requis) - ✅ Complètement offline (pas d'internet requis)
- ✅ Excellente précision (qualité OpenAI Whisper) - ✅ Excellente précision (qualité OpenAI Whisper)
- ✅ Gratuit, pas de limite d'utilisation - ✅ Gratuit, pas de limite d'utilisation
- ✅ Support multilingue (99 langues) - ✅ Support multilingue (99 langues)
- ❌ Plus lent que les APIs cloud (temps réel difficile) - ❌ Plus lent que les APIs cloud (temps réel difficile)
### Installation ### Installation
**Modèle téléchargé** : `models/ggml-base.bin` (142MB) **Modèle téléchargé** : `models/ggml-base.bin` (142MB)
Autres modèles disponibles : Autres modèles disponibles :
```bash ```bash
cd models/ cd models/
# Tiny (75MB) - Rapide mais moins précis # Tiny (75MB) - Rapide mais moins précis
curl -L -o ggml-tiny.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin curl -L -o ggml-tiny.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin
# Small (466MB) - Bon compromis # Small (466MB) - Bon compromis
curl -L -o ggml-small.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin curl -L -o ggml-small.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin
# Medium (1.5GB) - Très bonne qualité # Medium (1.5GB) - Très bonne qualité
curl -L -o ggml-medium.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin curl -L -o ggml-medium.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin
# Large (2.9GB) - Meilleure qualité # Large (2.9GB) - Meilleure qualité
curl -L -o ggml-large-v3.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin curl -L -o ggml-large-v3.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin
``` ```
**Recommandé** : `base` ou `small` pour la plupart des usages. **Recommandé** : `base` ou `small` pour la plupart des usages.
--- ---
## 2. OpenAI Whisper API ✅ ## 2. OpenAI Whisper API ✅
### Avantages ### Avantages
- ✅ Très rapide (temps réel) - ✅ Très rapide (temps réel)
- ✅ Excellente précision - ✅ Excellente précision
- ✅ Support multilingue - ✅ Support multilingue
- ❌ Requiert internet - ❌ Requiert internet
- ❌ Coût : $0.006/minute ($0.36/heure) - ❌ Coût : $0.006/minute ($0.36/heure)
### Configuration ### Configuration
1. Obtenir une clé API OpenAI : https://platform.openai.com/api-keys 1. Obtenir une clé API OpenAI : https://platform.openai.com/api-keys
2. Ajouter à `.env` : 2. Ajouter à `.env` :
```bash ```bash
OPENAI_API_KEY=sk-proj-... OPENAI_API_KEY=sk-proj-...
``` ```
**Status** : ✅ Déjà configuré **Status** : ✅ Déjà configuré
--- ---
## 3. Google Speech-to-Text ✅ ## 3. Google Speech-to-Text ✅
### Avantages ### Avantages
- ✅ Très rapide - ✅ Très rapide
- ✅ Bonne précision - ✅ Bonne précision
- ✅ Support multilingue (125+ langues) - ✅ Support multilingue (125+ langues)
- ❌ Requiert internet - ❌ Requiert internet
- ❌ Coût : $0.006/15s ($1.44/heure) - ❌ Coût : $0.006/15s ($1.44/heure)
### Configuration ### Configuration
1. Activer l'API : https://console.cloud.google.com/apis/library/speech.googleapis.com 1. Activer l'API : https://console.cloud.google.com/apis/library/speech.googleapis.com
2. Créer une clé API 2. Créer une clé API
3. Ajouter à `.env` : 3. Ajouter à `.env` :
```bash ```bash
GOOGLE_API_KEY=AIzaSy... GOOGLE_API_KEY=AIzaSy...
``` ```
**Status** : ✅ Déjà configuré **Status** : ✅ Déjà configuré
--- ---
## 4. Azure Speech-to-Text (Optionnel) ## 4. Azure Speech-to-Text (Optionnel)
### Avantages ### Avantages
- ✅ Excellente précision - ✅ Excellente précision
- ✅ Support multilingue - ✅ Support multilingue
- ✅ Free tier : 5h/mois gratuit - ✅ Free tier : 5h/mois gratuit
- ❌ Requiert internet - ❌ Requiert internet
### Configuration ### Configuration
1. Créer une ressource Azure Speech : https://portal.azure.com 1. Créer une ressource Azure Speech : https://portal.azure.com
2. Copier la clé et la région 2. Copier la clé et la région
3. Ajouter à `.env` : 3. Ajouter à `.env` :
```bash ```bash
AZURE_SPEECH_KEY=votre_cle_azure AZURE_SPEECH_KEY=votre_cle_azure
AZURE_SPEECH_REGION=westeurope # ou votre région AZURE_SPEECH_REGION=westeurope # ou votre région
``` ```
**Status** : ⚠️ Optionnel (non configuré) **Status** : ⚠️ Optionnel (non configuré)
--- ---
## 5. Deepgram (Optionnel) ## 5. Deepgram (Optionnel)
### Avantages ### Avantages
- ✅ Très rapide (streaming temps réel) - ✅ Très rapide (streaming temps réel)
- ✅ Bonne précision - ✅ Bonne précision
- ✅ Free tier : $200 crédit / 45,000 minutes - ✅ Free tier : $200 crédit / 45,000 minutes
- ❌ Requiert internet - ❌ Requiert internet
### Configuration ### Configuration
1. Créer un compte : https://console.deepgram.com 1. Créer un compte : https://console.deepgram.com
2. Créer une API key 2. Créer une API key
3. Ajouter à `.env` : 3. Ajouter à `.env` :
```bash ```bash
DEEPGRAM_API_KEY=votre_cle_deepgram DEEPGRAM_API_KEY=votre_cle_deepgram
``` ```
**Status** : ⚠️ Optionnel (non configuré) **Status** : ⚠️ Optionnel (non configuré)
--- ---
## Tester les Moteurs STT ## Tester les Moteurs STT
### Option 1 : Test avec fichier audio ### Option 1 : Test avec fichier audio
1. Générer un fichier audio de test : 1. Générer un fichier audio de test :
```bash ```bash
python create_test_audio_simple.py python create_test_audio_simple.py
``` ```
2. Lancer le test (quand compilé) : 2. Lancer le test (quand compilé) :
```bash ```bash
./build/test_stt_live test_audio.wav ./build/test_stt_live test_audio.wav
``` ```
Ceci testera automatiquement tous les moteurs disponibles. Ceci testera automatiquement tous les moteurs disponibles.
### Option 2 : Test depuis AISSIA ### Option 2 : Test depuis AISSIA
Les moteurs STT sont intégrés dans `VoiceModule` et accessibles via : Les moteurs STT sont intégrés dans `VoiceModule` et accessibles via :
- `voice:start_listening` (pub/sub) - `voice:start_listening` (pub/sub)
- `voice:stop_listening` - `voice:stop_listening`
- `voice:transcribe` (avec fichier audio) - `voice:transcribe` (avec fichier audio)
--- ---
## Configuration Recommandée ## Configuration Recommandée
Pour un usage optimal, voici l'ordre de priorité recommandé : Pour un usage optimal, voici l'ordre de priorité recommandé :
### Pour développement/tests locaux ### Pour développement/tests locaux
1. **Whisper.cpp** (`ggml-base.bin`) - Offline, gratuit 1. **Whisper.cpp** (`ggml-base.bin`) - Offline, gratuit
2. **OpenAI Whisper API** - Si internet disponible 2. **OpenAI Whisper API** - Si internet disponible
3. **Google Speech** - Fallback 3. **Google Speech** - Fallback
### Pour production/temps réel ### Pour production/temps réel
1. **Deepgram** - Meilleur streaming temps réel 1. **Deepgram** - Meilleur streaming temps réel
2. **Azure STT** - Bonne qualité, free tier 2. **Azure STT** - Bonne qualité, free tier
3. **Whisper.cpp** (`ggml-small.bin`) - Offline fallback 3. **Whisper.cpp** (`ggml-small.bin`) - Offline fallback
--- ---
## Fichiers de Configuration ## Fichiers de Configuration
### .env (API Keys) ### .env (API Keys)
```bash ```bash
# OpenAI Whisper API (✅ configuré) # OpenAI Whisper API (✅ configuré)
OPENAI_API_KEY=sk-proj-... OPENAI_API_KEY=sk-proj-...
# Google Speech (✅ configuré) # Google Speech (✅ configuré)
GOOGLE_API_KEY=AIzaSy... GOOGLE_API_KEY=AIzaSy...
# Azure STT (optionnel) # Azure STT (optionnel)
#AZURE_SPEECH_KEY=votre_cle #AZURE_SPEECH_KEY=votre_cle
#AZURE_SPEECH_REGION=westeurope #AZURE_SPEECH_REGION=westeurope
# Deepgram (optionnel) # Deepgram (optionnel)
#DEEPGRAM_API_KEY=votre_cle #DEEPGRAM_API_KEY=votre_cle
``` ```
### config/voice.json ### config/voice.json
```json ```json
{ {
"stt": { "stt": {
"active_mode": { "active_mode": {
"enabled": true, "enabled": true,
"engine": "whisper_cpp", "engine": "whisper_cpp",
"model_path": "./models/ggml-base.bin", "model_path": "./models/ggml-base.bin",
"language": "fr", "language": "fr",
"fallback_engine": "whisper_api" "fallback_engine": "whisper_api"
} }
} }
} }
``` ```
--- ---
## Dépendances ## Dépendances
### Whisper.cpp ### Whisper.cpp
- ✅ Intégré dans le build (external/whisper.cpp) - ✅ Intégré dans le build (external/whisper.cpp)
- ✅ Lié statiquement à AissiaAudio - ✅ Lié statiquement à AissiaAudio
- ❌ Modèle requis : téléchargé dans `models/` - ❌ Modèle requis : téléchargé dans `models/`
### APIs Cloud ### APIs Cloud
- ✅ Httplib pour requêtes HTTP (déjà dans le projet) - ✅ Httplib pour requêtes HTTP (déjà dans le projet)
- ✅ nlohmann/json pour sérialisation (déjà dans le projet) - ✅ nlohmann/json pour sérialisation (déjà dans le projet)
- ❌ OpenSSL désactivé (HTTP-only mode OK) - ❌ OpenSSL désactivé (HTTP-only mode OK)
--- ---
## Troubleshooting ## Troubleshooting
### "Whisper model not found" ### "Whisper model not found"
```bash ```bash
cd models/ cd models/
curl -L -o ggml-base.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin curl -L -o ggml-base.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin
``` ```
### "API key not found" ### "API key not found"
Vérifier que `.env` contient les clés et est chargé : Vérifier que `.env` contient les clés et est chargé :
```bash ```bash
cat .env | grep -E "OPENAI|GOOGLE|AZURE|DEEPGRAM" cat .env | grep -E "OPENAI|GOOGLE|AZURE|DEEPGRAM"
``` ```
### "Transcription failed" ### "Transcription failed"
1. Vérifier le format audio : 16kHz, mono, 16-bit PCM WAV 1. Vérifier le format audio : 16kHz, mono, 16-bit PCM WAV
2. Générer un test : `python create_test_audio_simple.py` 2. Générer un test : `python create_test_audio_simple.py`
3. Activer les logs : `spdlog::set_level(spdlog::level::debug)` 3. Activer les logs : `spdlog::set_level(spdlog::level::debug)`
--- ---
## Prochaines Étapes ## Prochaines Étapes
1. ✅ Whisper.cpp configuré et fonctionnel 1. ✅ Whisper.cpp configuré et fonctionnel
2. ✅ OpenAI + Google APIs configurées 2. ✅ OpenAI + Google APIs configurées
3. ⚠️ Optionnel : Ajouter Azure ou Deepgram pour redondance 3. ⚠️ Optionnel : Ajouter Azure ou Deepgram pour redondance
4. 🔜 Tester avec `./build/test_stt_live test_audio.wav` 4. 🔜 Tester avec `./build/test_stt_live test_audio.wav`
5. 🔜 Intégrer dans VoiceModule via pub/sub 5. 🔜 Intégrer dans VoiceModule via pub/sub
--- ---
## Références ## Références
- [Whisper.cpp GitHub](https://github.com/ggerganov/whisper.cpp) - [Whisper.cpp GitHub](https://github.com/ggerganov/whisper.cpp)
- [OpenAI Whisper API](https://platform.openai.com/docs/guides/speech-to-text) - [OpenAI Whisper API](https://platform.openai.com/docs/guides/speech-to-text)
- [Google Speech-to-Text](https://cloud.google.com/speech-to-text) - [Google Speech-to-Text](https://cloud.google.com/speech-to-text)
- [Azure Speech](https://azure.microsoft.com/en-us/services/cognitive-services/speech-to-text/) - [Azure Speech](https://azure.microsoft.com/en-us/services/cognitive-services/speech-to-text/)
- [Deepgram](https://developers.deepgram.com/) - [Deepgram](https://developers.deepgram.com/)

View File

@ -1,227 +1,227 @@
# Document de Succession - AISSIA # Document de Succession - AISSIA
## Contexte ## Contexte
AISSIA = Assistant vocal agentique basé sur GroveEngine (C++17 hot-reload). Architecture "Claude Code en vocal" avec tools internes + FileSystem + MCP. AISSIA = Assistant vocal agentique basé sur GroveEngine (C++17 hot-reload). Architecture "Claude Code en vocal" avec tools internes + FileSystem + MCP.
**Dernier commit** : `37b62b5` **Dernier commit** : `37b62b5`
## État Actuel ## État Actuel
### Ce qui fonctionne ### Ce qui fonctionne
**Build complet** - `cmake -B build && cmake --build build -j4` **Build complet** - `cmake -B build && cmake --build build -j4`
**6 modules hot-reload** - Scheduler, Notification, Monitoring, AI, Voice, Storage **6 modules hot-reload** - Scheduler, Notification, Monitoring, AI, Voice, Storage
**4 services** - LLMService, StorageService, PlatformService, VoiceService **4 services** - LLMService, StorageService, PlatformService, VoiceService
**17 tools pour l'agent** : **17 tools pour l'agent** :
- 11 tools internes (via IIO pub/sub) - 11 tools internes (via IIO pub/sub)
- 6 FileSystem tools (read/write/edit/list/glob/grep) - 6 FileSystem tools (read/write/edit/list/glob/grep)
- MCP tools (désactivés par défaut) - MCP tools (désactivés par défaut)
**Tests** - 67/75 tests modules+types passent **Tests** - 67/75 tests modules+types passent
### Lancement ### Lancement
```bash ```bash
# Build # Build
cmake -B build && cmake --build build -j4 cmake -B build && cmake --build build -j4
# Run (depuis racine ou build/) # Run (depuis racine ou build/)
./build/aissia ./build/aissia
# Mode MCP Server (expose les tools via JSON-RPC stdio) # Mode MCP Server (expose les tools via JSON-RPC stdio)
./build/aissia --mcp-server ./build/aissia --mcp-server
# Tests # Tests
cmake -B build -DBUILD_TESTING=ON cmake -B build -DBUILD_TESTING=ON
./build/tests/aissia_tests "[scheduler],[notification]" # Modules ./build/tests/aissia_tests "[scheduler],[notification]" # Modules
./build/tests/aissia_tests "[types]" # MCP types ./build/tests/aissia_tests "[types]" # MCP types
``` ```
### Variables d'Environnement ### Variables d'Environnement
```bash ```bash
export ANTHROPIC_API_KEY="sk-ant-..." # Requis pour Claude API export ANTHROPIC_API_KEY="sk-ant-..." # Requis pour Claude API
``` ```
## Architecture ## Architecture
``` ```
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ LLMService │ │ LLMService │
│ (Agentic Loop) │ │ (Agentic Loop) │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ ToolRegistry │ │ ToolRegistry │
│ ├── InternalTools (11) ─────► IIO pub/sub │ │ ├── InternalTools (11) ─────► IIO pub/sub │
│ ├── FileSystemTools (6) ────► Direct C++ (read/write/edit) │ │ ├── FileSystemTools (6) ────► Direct C++ (read/write/edit) │
│ └── MCPClient (optionnel) ──► stdio JSON-RPC │ │ └── MCPClient (optionnel) ──► stdio JSON-RPC │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
┌──────────────┬─────┴──────┬──────────────┐ ┌──────────────┬─────┴──────┬──────────────┐
Scheduler Monitoring Storage Voice Scheduler Monitoring Storage Voice
Module Module Module Module Module Module Module Module
``` ```
### Tools Disponibles ### Tools Disponibles
| Catégorie | Tools | Communication | | Catégorie | Tools | Communication |
|-----------|-------|---------------| |-----------|-------|---------------|
| Scheduler | get_current_task, list_tasks, start_task, complete_task, start_break | IIO | | Scheduler | get_current_task, list_tasks, start_task, complete_task, start_break | IIO |
| Monitoring | get_focus_stats, get_current_app | IIO | | Monitoring | get_focus_stats, get_current_app | IIO |
| Storage | save_note, query_notes, get_session_history | IIO | | Storage | save_note, query_notes, get_session_history | IIO |
| Voice | speak | IIO | | Voice | speak | IIO |
| FileSystem | read_file, write_file, edit_file, list_directory, glob_files, grep_files | Direct C++ | | FileSystem | read_file, write_file, edit_file, list_directory, glob_files, grep_files | Direct C++ |
### FileSystem Tools (Nouveau) ### FileSystem Tools (Nouveau)
Implémentés dans `src/shared/tools/FileSystemTools.*` : Implémentés dans `src/shared/tools/FileSystemTools.*` :
```cpp ```cpp
// Lecture avec numéros de ligne // Lecture avec numéros de ligne
FileSystemTools::execute("read_file", {{"path", "/path/to/file"}, {"limit", 10}}); FileSystemTools::execute("read_file", {{"path", "/path/to/file"}, {"limit", 10}});
// Édition style Claude Code // Édition style Claude Code
FileSystemTools::execute("edit_file", { FileSystemTools::execute("edit_file", {
{"path", "/path/to/file"}, {"path", "/path/to/file"},
{"old_string", "foo"}, {"old_string", "foo"},
{"new_string", "bar"} {"new_string", "bar"}
}); });
// Recherche // Recherche
FileSystemTools::execute("glob_files", {{"pattern", "**/*.cpp"}}); FileSystemTools::execute("glob_files", {{"pattern", "**/*.cpp"}});
FileSystemTools::execute("grep_files", {{"pattern", "TODO"}, {"path", "./src"}}); FileSystemTools::execute("grep_files", {{"pattern", "TODO"}, {"path", "./src"}});
``` ```
**Sécurité** : **Sécurité** :
- Chemins autorisés configurables - Chemins autorisés configurables
- Patterns bloqués : `*.env`, `*.key`, `*credentials*` - Patterns bloqués : `*.env`, `*.key`, `*credentials*`
- Limites : 1MB lecture, 10MB écriture - Limites : 1MB lecture, 10MB écriture
## Fichiers Clés ## Fichiers Clés
### Nouveaux (Session actuelle) ### Nouveaux (Session actuelle)
``` ```
src/shared/tools/FileSystemTools.hpp src/shared/tools/FileSystemTools.hpp
src/shared/tools/FileSystemTools.cpp src/shared/tools/FileSystemTools.cpp
PLAN_FILESYSTEM_TOOLS.md PLAN_FILESYSTEM_TOOLS.md
``` ```
### Services ### Services
``` ```
src/services/LLMService.* # Agentic loop, tools registry src/services/LLMService.* # Agentic loop, tools registry
src/services/StorageService.* # SQLite persistence src/services/StorageService.* # SQLite persistence
src/services/PlatformService.* # Window tracking src/services/PlatformService.* # Window tracking
src/services/VoiceService.* # TTS/STT src/services/VoiceService.* # TTS/STT
``` ```
### Modules (Hot-Reload) ### Modules (Hot-Reload)
``` ```
src/modules/SchedulerModule.* src/modules/SchedulerModule.*
src/modules/NotificationModule.* src/modules/NotificationModule.*
src/modules/MonitoringModule.* src/modules/MonitoringModule.*
src/modules/AIModule.* src/modules/AIModule.*
src/modules/VoiceModule.* src/modules/VoiceModule.*
src/modules/StorageModule.* src/modules/StorageModule.*
``` ```
### MCP ### MCP
``` ```
src/shared/mcp/MCPTypes.hpp src/shared/mcp/MCPTypes.hpp
src/shared/mcp/MCPClient.* # Client MCP (consomme des serveurs externes) src/shared/mcp/MCPClient.* # Client MCP (consomme des serveurs externes)
src/shared/mcp/MCPServer.* # Serveur MCP (expose AISSIA comme serveur) src/shared/mcp/MCPServer.* # Serveur MCP (expose AISSIA comme serveur)
src/shared/mcp/StdioTransport.* src/shared/mcp/StdioTransport.*
config/mcp.json config/mcp.json
``` ```
## Tests ## Tests
```bash ```bash
# Build avec tests # Build avec tests
cmake -B build -DBUILD_TESTING=ON && cmake --build build -j4 cmake -B build -DBUILD_TESTING=ON && cmake --build build -j4
# Par catégorie # Par catégorie
./build/tests/aissia_tests "[scheduler]" # 10 tests ./build/tests/aissia_tests "[scheduler]" # 10 tests
./build/tests/aissia_tests "[notification]" # 10 tests ./build/tests/aissia_tests "[notification]" # 10 tests
./build/tests/aissia_tests "[types]" # 15 tests MCP ./build/tests/aissia_tests "[types]" # 15 tests MCP
# Tous les modules # Tous les modules
./build/tests/aissia_tests "[scheduler],[notification],[monitoring],[ai],[voice],[storage]" ./build/tests/aissia_tests "[scheduler],[notification],[monitoring],[ai],[voice],[storage]"
``` ```
**Résultats actuels** : **Résultats actuels** :
- Modules : 52/60 (87%) - Modules : 52/60 (87%)
- MCP Types : 15/15 (100%) - MCP Types : 15/15 (100%)
- MCP Transport/Client : Nécessite fix serveurs Python - MCP Transport/Client : Nécessite fix serveurs Python
## Prochaines Étapes ## Prochaines Étapes
### Priorité Haute ### Priorité Haute
1. **Tester avec API key** - Vérifier la boucle agentique complète 1. **Tester avec API key** - Vérifier la boucle agentique complète
2. **Activer MCP filesystem** - Pour tests end-to-end avec tools externes 2. **Activer MCP filesystem** - Pour tests end-to-end avec tools externes
### Priorité Moyenne ### Priorité Moyenne
3. **Fixer tests MCP Transport** - Les serveurs Python reçoivent EOF 3. **Fixer tests MCP Transport** - Les serveurs Python reçoivent EOF
4. **Ajouter plus de tools** - add_task, set_reminder, etc. 4. **Ajouter plus de tools** - add_task, set_reminder, etc.
5. **Streaming responses** - Feedback temps réel pendant génération 5. **Streaming responses** - Feedback temps réel pendant génération
### Priorité Basse ### Priorité Basse
6. **Tests end-to-end** - Flux complet inter-modules 6. **Tests end-to-end** - Flux complet inter-modules
7. **CI/CD** - GitHub Actions 7. **CI/CD** - GitHub Actions
8. **Documentation API** - Doxygen 8. **Documentation API** - Doxygen
## MCP Server Mode ## MCP Server Mode
AISSIA peut fonctionner comme **serveur MCP**, exposant ses tools à des clients externes via JSON-RPC sur stdio. AISSIA peut fonctionner comme **serveur MCP**, exposant ses tools à des clients externes via JSON-RPC sur stdio.
```bash ```bash
./build/aissia --mcp-server ./build/aissia --mcp-server
``` ```
### Protocole ### Protocole
Communication JSON-RPC 2.0 sur stdin/stdout : Communication JSON-RPC 2.0 sur stdin/stdout :
```json ```json
// Client → AISSIA // Client → AISSIA
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"1.0"}}} {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"1.0"}}}
// AISSIA → Client // AISSIA → Client
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","serverInfo":{"name":"aissia","version":"0.2.0"},...}} {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","serverInfo":{"name":"aissia","version":"0.2.0"},...}}
// Lister les tools // Lister les tools
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
// Appeler un tool // Appeler un tool
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_directory","arguments":{"path":"."}}} {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_directory","arguments":{"path":"."}}}
``` ```
### Utilisation avec Claude Code ### Utilisation avec Claude Code
Ajouter dans la config MCP : Ajouter dans la config MCP :
```json ```json
{ {
"servers": { "servers": {
"aissia": { "aissia": {
"command": "/chemin/vers/build/aissia", "command": "/chemin/vers/build/aissia",
"args": ["--mcp-server"] "args": ["--mcp-server"]
} }
} }
} }
``` ```
### Tools Exposés (actuellement) ### Tools Exposés (actuellement)
6 FileSystem tools. TODO: exposer les tools internes (scheduler, voice, etc.). 6 FileSystem tools. TODO: exposer les tools internes (scheduler, voice, etc.).
## Notes Techniques ## Notes Techniques
### WSL ### WSL
- Window tracker non disponible (stub utilisé) - Window tracker non disponible (stub utilisé)
- espeak non installé (TTS stub) - espeak non installé (TTS stub)
- Tout le reste fonctionne - Tout le reste fonctionne
### Hot-Reload ### Hot-Reload
Les modules sont des `.so` chargés dynamiquement. Pour recompiler un module : Les modules sont des `.so` chargés dynamiquement. Pour recompiler un module :
```bash ```bash
cmake --build build --target SchedulerModule cmake --build build --target SchedulerModule
# Le module sera rechargé au prochain cycle si modifié # Le module sera rechargé au prochain cycle si modifié
``` ```

View File

@ -1,16 +1,16 @@
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
cd "C:\Users\alexi\Documents\projects\aissia" cd "C:\Users\alexi\Documents\projects\aissia"
Write-Host "=== Running aissia_tests.exe ===" -ForegroundColor Cyan Write-Host "=== Running aissia_tests.exe ===" -ForegroundColor Cyan
& ".\build\tests\aissia_tests.exe" 2>&1 | Tee-Object -FilePath "test_output.txt" & ".\build\tests\aissia_tests.exe" 2>&1 | Tee-Object -FilePath "test_output.txt"
$testExitCode = $LASTEXITCODE $testExitCode = $LASTEXITCODE
Write-Host "`nTest exit code: $testExitCode" -ForegroundColor $(if ($testExitCode -eq 0) { "Green" } else { "Red" }) Write-Host "`nTest exit code: $testExitCode" -ForegroundColor $(if ($testExitCode -eq 0) { "Green" } else { "Red" })
Write-Host "`n=== Running test_stt_engines.exe ===" -ForegroundColor Cyan Write-Host "`n=== Running test_stt_engines.exe ===" -ForegroundColor Cyan
& ".\build\test_stt_engines.exe" 2>&1 | Tee-Object -FilePath "stt_test_output.txt" -Append & ".\build\test_stt_engines.exe" 2>&1 | Tee-Object -FilePath "stt_test_output.txt" -Append
$sttExitCode = $LASTEXITCODE $sttExitCode = $LASTEXITCODE
Write-Host "`nSTT Test exit code: $sttExitCode" -ForegroundColor $(if ($sttExitCode -eq 0) { "Green" } else { "Red" }) Write-Host "`nSTT Test exit code: $sttExitCode" -ForegroundColor $(if ($sttExitCode -eq 0) { "Green" } else { "Red" })
Write-Host "`n=== Test Summary ===" -ForegroundColor Cyan Write-Host "`n=== Test Summary ===" -ForegroundColor Cyan
Write-Host "aissia_tests: $(if ($testExitCode -eq 0) { 'PASSED' } else { 'FAILED' })" Write-Host "aissia_tests: $(if ($testExitCode -eq 0) { 'PASSED' } else { 'FAILED' })"
Write-Host "test_stt_engines: $(if ($sttExitCode -eq 0) { 'PASSED' } else { 'FAILED' })" Write-Host "test_stt_engines: $(if ($sttExitCode -eq 0) { 'PASSED' } else { 'FAILED' })"

View File

@ -1,39 +1,39 @@
#pragma once #pragma once
#include <grove/IIO.h> #include <grove/IIO.h>
#include <string> #include <string>
namespace aissia { namespace aissia {
/** /**
* @brief Interface for infrastructure services * @brief Interface for infrastructure services
* *
* Services handle non-hot-reloadable infrastructure: * Services handle non-hot-reloadable infrastructure:
* - LLM HTTP calls * - LLM HTTP calls
* - SQLite database * - SQLite database
* - Platform APIs (Win32/X11) * - Platform APIs (Win32/X11)
* - TTS/STT engines * - TTS/STT engines
* *
* Services communicate with modules via IIO pub/sub. * Services communicate with modules via IIO pub/sub.
*/ */
class IService { class IService {
public: public:
virtual ~IService() = default; virtual ~IService() = default;
/// Initialize the service with IIO for pub/sub /// Initialize the service with IIO for pub/sub
virtual bool initialize(grove::IIO* io) = 0; virtual bool initialize(grove::IIO* io) = 0;
/// Process pending work (called each frame from main loop) /// Process pending work (called each frame from main loop)
virtual void process() = 0; virtual void process() = 0;
/// Clean shutdown /// Clean shutdown
virtual void shutdown() = 0; virtual void shutdown() = 0;
/// Service name for logging /// Service name for logging
virtual std::string getName() const = 0; virtual std::string getName() const = 0;
/// Check if service is healthy /// Check if service is healthy
virtual bool isHealthy() const = 0; virtual bool isHealthy() const = 0;
}; };
} // namespace aissia } // namespace aissia

View File

@ -1,375 +1,375 @@
#include "LLMService.hpp" #include "LLMService.hpp"
#include "../shared/llm/LLMProviderFactory.hpp" #include "../shared/llm/LLMProviderFactory.hpp"
#include <spdlog/sinks/stdout_color_sinks.h> #include <spdlog/sinks/stdout_color_sinks.h>
#include <fstream> #include <fstream>
namespace aissia { namespace aissia {
LLMService::LLMService() { LLMService::LLMService() {
m_logger = spdlog::get("LLMService"); m_logger = spdlog::get("LLMService");
if (!m_logger) { if (!m_logger) {
m_logger = spdlog::stdout_color_mt("LLMService"); m_logger = spdlog::stdout_color_mt("LLMService");
} }
} }
LLMService::~LLMService() { LLMService::~LLMService() {
shutdown(); shutdown();
} }
bool LLMService::initialize(grove::IIO* io) { bool LLMService::initialize(grove::IIO* io) {
m_io = io; m_io = io;
if (m_io) { if (m_io) {
grove::SubscriptionConfig config; grove::SubscriptionConfig config;
m_io->subscribe("llm:request", config); m_io->subscribe("llm:request", config);
} }
// Start worker thread // Start worker thread
m_running = true; m_running = true;
m_workerThread = std::thread(&LLMService::workerLoop, this); m_workerThread = std::thread(&LLMService::workerLoop, this);
m_logger->info("LLMService initialized"); m_logger->info("LLMService initialized");
return true; return true;
} }
bool LLMService::loadConfig(const std::string& configPath) { bool LLMService::loadConfig(const std::string& configPath) {
try { try {
std::ifstream file(configPath); std::ifstream file(configPath);
if (!file.is_open()) { if (!file.is_open()) {
m_logger->warn("Config file not found: {}", configPath); m_logger->warn("Config file not found: {}", configPath);
return false; return false;
} }
nlohmann::json config; nlohmann::json config;
file >> config; file >> config;
m_provider = LLMProviderFactory::create(config); m_provider = LLMProviderFactory::create(config);
if (!m_provider) { if (!m_provider) {
m_logger->error("Failed to create LLM provider"); m_logger->error("Failed to create LLM provider");
return false; return false;
} }
m_providerName = config.value("provider", "claude"); m_providerName = config.value("provider", "claude");
m_maxIterations = config.value("max_iterations", 10); m_maxIterations = config.value("max_iterations", 10);
m_defaultSystemPrompt = config.value("system_prompt", m_defaultSystemPrompt = config.value("system_prompt",
"Tu es AISSIA, un assistant personnel intelligent. " "Tu es AISSIA, un assistant personnel intelligent. "
"Tu peux utiliser des tools pour accomplir des taches: " "Tu peux utiliser des tools pour accomplir des taches: "
"gerer le planning, verifier le focus, sauvegarder des notes, " "gerer le planning, verifier le focus, sauvegarder des notes, "
"lire des fichiers, faire des recherches web, etc."); "lire des fichiers, faire des recherches web, etc.");
m_logger->info("LLM provider loaded: {} ({})", m_providerName, m_provider->getModel()); m_logger->info("LLM provider loaded: {} ({})", m_providerName, m_provider->getModel());
// Initialize tools after provider is ready // Initialize tools after provider is ready
initializeTools(); initializeTools();
return true; return true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
m_logger->error("Failed to load config: {}", e.what()); m_logger->error("Failed to load config: {}", e.what());
return false; return false;
} }
} }
void LLMService::initializeTools() { void LLMService::initializeTools() {
m_logger->info("Initializing tools..."); m_logger->info("Initializing tools...");
// 1. Internal tools (via GroveEngine IIO) // 1. Internal tools (via GroveEngine IIO)
if (m_io) { if (m_io) {
m_internalTools = std::make_unique<InternalTools>(m_io); m_internalTools = std::make_unique<InternalTools>(m_io);
for (const auto& tool : m_internalTools->getTools()) { for (const auto& tool : m_internalTools->getTools()) {
m_toolRegistry.registerTool(tool); m_toolRegistry.registerTool(tool);
} }
m_logger->info("Registered {} internal tools", m_internalTools->size()); m_logger->info("Registered {} internal tools", m_internalTools->size());
} }
// 2. FileSystem tools (direct C++ execution) // 2. FileSystem tools (direct C++ execution)
for (const auto& toolDef : tools::FileSystemTools::getToolDefinitions()) { for (const auto& toolDef : tools::FileSystemTools::getToolDefinitions()) {
std::string toolName = toolDef["name"].get<std::string>(); std::string toolName = toolDef["name"].get<std::string>();
m_toolRegistry.registerTool( m_toolRegistry.registerTool(
toolName, toolName,
toolDef["description"].get<std::string>(), toolDef["description"].get<std::string>(),
toolDef["input_schema"], toolDef["input_schema"],
[toolName](const nlohmann::json& input) -> nlohmann::json { [toolName](const nlohmann::json& input) -> nlohmann::json {
return tools::FileSystemTools::execute(toolName, input); return tools::FileSystemTools::execute(toolName, input);
} }
); );
} }
m_logger->info("Registered {} filesystem tools", tools::FileSystemTools::getToolDefinitions().size()); m_logger->info("Registered {} filesystem tools", tools::FileSystemTools::getToolDefinitions().size());
// 3. MCP tools (via external servers) // 3. MCP tools (via external servers)
m_mcpClient = std::make_unique<mcp::MCPClient>(); m_mcpClient = std::make_unique<mcp::MCPClient>();
if (loadMCPConfig("config/mcp.json")) { if (loadMCPConfig("config/mcp.json")) {
int connected = m_mcpClient->connectAll(); int connected = m_mcpClient->connectAll();
if (connected > 0) { if (connected > 0) {
for (const auto& tool : m_mcpClient->listAllTools()) { for (const auto& tool : m_mcpClient->listAllTools()) {
// Convert MCP tool to our ToolDefinition format // Convert MCP tool to our ToolDefinition format
m_toolRegistry.registerTool( m_toolRegistry.registerTool(
tool.name, tool.name,
tool.description, tool.description,
tool.inputSchema, tool.inputSchema,
[this, toolName = tool.name](const nlohmann::json& input) -> nlohmann::json { [this, toolName = tool.name](const nlohmann::json& input) -> nlohmann::json {
auto result = m_mcpClient->callTool(toolName, input); auto result = m_mcpClient->callTool(toolName, input);
// Convert MCP result to simple JSON // Convert MCP result to simple JSON
if (result.isError) { if (result.isError) {
return {{"error", true}, {"content", result.content}}; return {{"error", true}, {"content", result.content}};
} }
// Extract text content // Extract text content
std::string text; std::string text;
for (const auto& content : result.content) { for (const auto& content : result.content) {
if (content.contains("text")) { if (content.contains("text")) {
text += content["text"].get<std::string>(); text += content["text"].get<std::string>();
} }
} }
return {{"content", text}}; return {{"content", text}};
} }
); );
} }
m_logger->info("Registered {} MCP tools from {} servers", m_logger->info("Registered {} MCP tools from {} servers",
m_mcpClient->toolCount(), connected); m_mcpClient->toolCount(), connected);
} }
} }
m_logger->info("Total tools available: {}", m_toolRegistry.size()); m_logger->info("Total tools available: {}", m_toolRegistry.size());
} }
bool LLMService::loadMCPConfig(const std::string& configPath) { bool LLMService::loadMCPConfig(const std::string& configPath) {
return m_mcpClient->loadConfig(configPath); return m_mcpClient->loadConfig(configPath);
} }
void LLMService::registerTool(const std::string& name, const std::string& description, void LLMService::registerTool(const std::string& name, const std::string& description,
const nlohmann::json& schema, const nlohmann::json& schema,
std::function<nlohmann::json(const nlohmann::json&)> handler) { std::function<nlohmann::json(const nlohmann::json&)> handler) {
m_toolRegistry.registerTool(name, description, schema, handler); m_toolRegistry.registerTool(name, description, schema, handler);
m_logger->debug("Tool registered: {}", name); m_logger->debug("Tool registered: {}", name);
} }
void LLMService::process() { void LLMService::process() {
processIncomingMessages(); processIncomingMessages();
publishResponses(); publishResponses();
} }
void LLMService::processIncomingMessages() { void LLMService::processIncomingMessages() {
if (!m_io) return; if (!m_io) return;
while (m_io->hasMessages() > 0) { while (m_io->hasMessages() > 0) {
auto msg = m_io->pullMessage(); auto msg = m_io->pullMessage();
if (msg.topic == "llm:request" && msg.data) { if (msg.topic == "llm:request" && msg.data) {
Request req; Request req;
req.query = msg.data->getString("query", ""); req.query = msg.data->getString("query", "");
req.systemPrompt = msg.data->getString("systemPrompt", m_defaultSystemPrompt); req.systemPrompt = msg.data->getString("systemPrompt", m_defaultSystemPrompt);
req.conversationId = msg.data->getString("conversationId", "default"); req.conversationId = msg.data->getString("conversationId", "default");
req.maxIterations = msg.data->getInt("maxIterations", m_maxIterations); req.maxIterations = msg.data->getInt("maxIterations", m_maxIterations);
// Get tools from message or use registered tools // Get tools from message or use registered tools
auto* toolsNode = msg.data->getChildReadOnly("tools"); auto* toolsNode = msg.data->getChildReadOnly("tools");
if (toolsNode) { if (toolsNode) {
// Custom tools from message // Custom tools from message
// (would need to parse from IDataNode) // (would need to parse from IDataNode)
} }
if (!req.query.empty()) { if (!req.query.empty()) {
std::lock_guard<std::mutex> lock(m_requestMutex); std::lock_guard<std::mutex> lock(m_requestMutex);
m_requestQueue.push(std::move(req)); m_requestQueue.push(std::move(req));
m_requestCV.notify_one(); m_requestCV.notify_one();
m_logger->debug("Request queued: {}", req.query.substr(0, 50)); m_logger->debug("Request queued: {}", req.query.substr(0, 50));
} }
} }
} }
} }
void LLMService::publishResponses() { void LLMService::publishResponses() {
if (!m_io) return; if (!m_io) return;
std::lock_guard<std::mutex> lock(m_responseMutex); std::lock_guard<std::mutex> lock(m_responseMutex);
while (!m_responseQueue.empty()) { while (!m_responseQueue.empty()) {
auto resp = std::move(m_responseQueue.front()); auto resp = std::move(m_responseQueue.front());
m_responseQueue.pop(); m_responseQueue.pop();
if (resp.isError) { if (resp.isError) {
auto event = std::make_unique<grove::JsonDataNode>("error"); auto event = std::make_unique<grove::JsonDataNode>("error");
event->setString("message", resp.text); event->setString("message", resp.text);
event->setString("conversationId", resp.conversationId); event->setString("conversationId", resp.conversationId);
m_io->publish("llm:error", std::move(event)); m_io->publish("llm:error", std::move(event));
} else { } else {
auto event = std::make_unique<grove::JsonDataNode>("response"); auto event = std::make_unique<grove::JsonDataNode>("response");
event->setString("text", resp.text); event->setString("text", resp.text);
event->setString("conversationId", resp.conversationId); event->setString("conversationId", resp.conversationId);
event->setInt("tokens", resp.tokens); event->setInt("tokens", resp.tokens);
event->setInt("iterations", resp.iterations); event->setInt("iterations", resp.iterations);
m_io->publish("llm:response", std::move(event)); m_io->publish("llm:response", std::move(event));
m_logger->info("Response published: {} chars", resp.text.size()); m_logger->info("Response published: {} chars", resp.text.size());
} }
} }
} }
void LLMService::workerLoop() { void LLMService::workerLoop() {
m_logger->debug("Worker thread started"); m_logger->debug("Worker thread started");
while (m_running) { while (m_running) {
Request req; Request req;
{ {
std::unique_lock<std::mutex> lock(m_requestMutex); std::unique_lock<std::mutex> lock(m_requestMutex);
m_requestCV.wait_for(lock, std::chrono::milliseconds(100), [this] { m_requestCV.wait_for(lock, std::chrono::milliseconds(100), [this] {
return !m_requestQueue.empty() || !m_running; return !m_requestQueue.empty() || !m_running;
}); });
if (!m_running) break; if (!m_running) break;
if (m_requestQueue.empty()) continue; if (m_requestQueue.empty()) continue;
req = std::move(m_requestQueue.front()); req = std::move(m_requestQueue.front());
m_requestQueue.pop(); m_requestQueue.pop();
} }
// Process request (HTTP calls happen here) // Process request (HTTP calls happen here)
auto resp = processRequest(req); auto resp = processRequest(req);
{ {
std::lock_guard<std::mutex> lock(m_responseMutex); std::lock_guard<std::mutex> lock(m_responseMutex);
m_responseQueue.push(std::move(resp)); m_responseQueue.push(std::move(resp));
} }
} }
m_logger->debug("Worker thread stopped"); m_logger->debug("Worker thread stopped");
} }
LLMService::Response LLMService::processRequest(const Request& request) { LLMService::Response LLMService::processRequest(const Request& request) {
Response resp; Response resp;
resp.conversationId = request.conversationId; resp.conversationId = request.conversationId;
if (!m_provider) { if (!m_provider) {
resp.text = "LLM provider not initialized"; resp.text = "LLM provider not initialized";
resp.isError = true; resp.isError = true;
return resp; return resp;
} }
try { try {
// Get or create conversation history // Get or create conversation history
auto& history = m_conversations[request.conversationId]; auto& history = m_conversations[request.conversationId];
if (history.is_null()) { if (history.is_null()) {
history = nlohmann::json::array(); history = nlohmann::json::array();
} }
// Add user message // Add user message
history.push_back({{"role", "user"}, {"content", request.query}}); history.push_back({{"role", "user"}, {"content", request.query}});
// Get tool definitions // Get tool definitions
nlohmann::json tools = m_toolRegistry.getToolDefinitions(); nlohmann::json tools = m_toolRegistry.getToolDefinitions();
// Run agentic loop // Run agentic loop
auto result = agenticLoop(request.query, request.systemPrompt, auto result = agenticLoop(request.query, request.systemPrompt,
history, tools, request.maxIterations); history, tools, request.maxIterations);
if (result.contains("error")) { if (result.contains("error")) {
resp.text = result["error"].get<std::string>(); resp.text = result["error"].get<std::string>();
resp.isError = true; resp.isError = true;
} else { } else {
resp.text = result["response"].get<std::string>(); resp.text = result["response"].get<std::string>();
resp.tokens = result.value("tokens", 0); resp.tokens = result.value("tokens", 0);
resp.iterations = result.value("iterations", 1); resp.iterations = result.value("iterations", 1);
// Add assistant response to history // Add assistant response to history
history.push_back({{"role", "assistant"}, {"content", resp.text}}); history.push_back({{"role", "assistant"}, {"content", resp.text}});
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
resp.text = e.what(); resp.text = e.what();
resp.isError = true; resp.isError = true;
} }
return resp; return resp;
} }
nlohmann::json LLMService::agenticLoop(const std::string& query, const std::string& systemPrompt, nlohmann::json LLMService::agenticLoop(const std::string& query, const std::string& systemPrompt,
nlohmann::json& messages, const nlohmann::json& tools, nlohmann::json& messages, const nlohmann::json& tools,
int maxIterations) { int maxIterations) {
int totalTokens = 0; int totalTokens = 0;
for (int iteration = 0; iteration < maxIterations; iteration++) { for (int iteration = 0; iteration < maxIterations; iteration++) {
m_logger->debug("Agentic loop iteration {}", iteration + 1); m_logger->debug("Agentic loop iteration {}", iteration + 1);
auto response = m_provider->chat(systemPrompt, messages, tools); auto response = m_provider->chat(systemPrompt, messages, tools);
totalTokens += response.input_tokens + response.output_tokens; totalTokens += response.input_tokens + response.output_tokens;
if (response.is_end_turn) { if (response.is_end_turn) {
return { return {
{"response", response.text}, {"response", response.text},
{"iterations", iteration + 1}, {"iterations", iteration + 1},
{"tokens", totalTokens} {"tokens", totalTokens}
}; };
} }
// Execute tool calls // Execute tool calls
if (!response.tool_calls.empty()) { if (!response.tool_calls.empty()) {
std::vector<ToolResult> results; std::vector<ToolResult> results;
for (const auto& call : response.tool_calls) { for (const auto& call : response.tool_calls) {
m_logger->debug("Executing tool: {}", call.name); m_logger->debug("Executing tool: {}", call.name);
nlohmann::json result = m_toolRegistry.execute(call.name, call.input); nlohmann::json result = m_toolRegistry.execute(call.name, call.input);
results.push_back({call.id, result.dump(), false}); results.push_back({call.id, result.dump(), false});
} }
// Append assistant message and tool results // Append assistant message and tool results
m_provider->appendAssistantMessage(messages, response); m_provider->appendAssistantMessage(messages, response);
auto toolResultsMsg = m_provider->formatToolResults(results); auto toolResultsMsg = m_provider->formatToolResults(results);
if (toolResultsMsg.is_array()) { if (toolResultsMsg.is_array()) {
for (const auto& msg : toolResultsMsg) { for (const auto& msg : toolResultsMsg) {
messages.push_back(msg); messages.push_back(msg);
} }
} else { } else {
messages.push_back(toolResultsMsg); messages.push_back(toolResultsMsg);
} }
} }
} }
return {{"error", "max_iterations_reached"}}; return {{"error", "max_iterations_reached"}};
} }
void LLMService::shutdown() { void LLMService::shutdown() {
m_running = false; m_running = false;
m_requestCV.notify_all(); m_requestCV.notify_all();
if (m_workerThread.joinable()) { if (m_workerThread.joinable()) {
m_workerThread.join(); m_workerThread.join();
} }
m_logger->info("LLMService shutdown"); m_logger->info("LLMService shutdown");
} }
LLMService::SyncResponse LLMService::sendMessageSync( LLMService::SyncResponse LLMService::sendMessageSync(
const std::string& message, const std::string& message,
const std::string& conversationId, const std::string& conversationId,
const std::string& systemPrompt const std::string& systemPrompt
) { ) {
SyncResponse syncResp; SyncResponse syncResp;
// Create request (same as async mode) // Create request (same as async mode)
Request request; Request request;
request.query = message; request.query = message;
request.conversationId = conversationId.empty() ? "mcp-session" : conversationId; request.conversationId = conversationId.empty() ? "mcp-session" : conversationId;
request.systemPrompt = systemPrompt.empty() ? m_defaultSystemPrompt : systemPrompt; request.systemPrompt = systemPrompt.empty() ? m_defaultSystemPrompt : systemPrompt;
request.maxIterations = m_maxIterations; request.maxIterations = m_maxIterations;
// Process synchronously (blocking call) // Process synchronously (blocking call)
auto response = processRequest(request); auto response = processRequest(request);
// Convert to SyncResponse // Convert to SyncResponse
if (!response.isError) { if (!response.isError) {
syncResp.text = response.text; syncResp.text = response.text;
syncResp.tokens = response.tokens; syncResp.tokens = response.tokens;
syncResp.iterations = response.iterations; syncResp.iterations = response.iterations;
} else { } else {
// On error, return error in text // On error, return error in text
syncResp.text = "Error: " + response.text; syncResp.text = "Error: " + response.text;
syncResp.tokens = 0; syncResp.tokens = 0;
syncResp.iterations = 0; syncResp.iterations = 0;
} }
return syncResp; return syncResp;
} }
} // namespace aissia } // namespace aissia

View File

@ -1,140 +1,140 @@
#pragma once #pragma once
#include "IService.hpp" #include "IService.hpp"
#include "../shared/llm/ILLMProvider.hpp" #include "../shared/llm/ILLMProvider.hpp"
#include "../shared/llm/ToolRegistry.hpp" #include "../shared/llm/ToolRegistry.hpp"
#include "../shared/tools/InternalTools.hpp" #include "../shared/tools/InternalTools.hpp"
#include "../shared/tools/FileSystemTools.hpp" #include "../shared/tools/FileSystemTools.hpp"
#include "../shared/mcp/MCPClient.hpp" #include "../shared/mcp/MCPClient.hpp"
#include <grove/IIO.h> #include <grove/IIO.h>
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <memory> #include <memory>
#include <string> #include <string>
#include <queue> #include <queue>
#include <mutex> #include <mutex>
#include <thread> #include <thread>
#include <atomic> #include <atomic>
#include <condition_variable> #include <condition_variable>
namespace aissia { namespace aissia {
/** /**
* @brief LLM Service - Async HTTP calls to LLM providers * @brief LLM Service - Async HTTP calls to LLM providers
* *
* Handles all LLM API calls in a background thread. * Handles all LLM API calls in a background thread.
* Modules communicate via IIO: * Modules communicate via IIO:
* *
* Subscribes to: * Subscribes to:
* - "llm:request" : { query, systemPrompt?, tools?, conversationId? } * - "llm:request" : { query, systemPrompt?, tools?, conversationId? }
* *
* Publishes: * Publishes:
* - "llm:response" : { text, conversationId, tokens, iterations } * - "llm:response" : { text, conversationId, tokens, iterations }
* - "llm:error" : { message, conversationId } * - "llm:error" : { message, conversationId }
* - "llm:thinking" : { conversationId } (during agentic loop) * - "llm:thinking" : { conversationId } (during agentic loop)
*/ */
class LLMService : public IService { class LLMService : public IService {
public: public:
LLMService(); LLMService();
~LLMService() override; ~LLMService() override;
bool initialize(grove::IIO* io) override; bool initialize(grove::IIO* io) override;
void process() override; void process() override;
void shutdown() override; void shutdown() override;
std::string getName() const override { return "LLMService"; } std::string getName() const override { return "LLMService"; }
bool isHealthy() const override { return m_provider != nullptr; } bool isHealthy() const override { return m_provider != nullptr; }
/// Load provider from config file /// Load provider from config file
bool loadConfig(const std::string& configPath); bool loadConfig(const std::string& configPath);
/// Register a tool that can be called by the LLM /// Register a tool that can be called by the LLM
void registerTool(const std::string& name, const std::string& description, void registerTool(const std::string& name, const std::string& description,
const nlohmann::json& schema, const nlohmann::json& schema,
std::function<nlohmann::json(const nlohmann::json&)> handler); std::function<nlohmann::json(const nlohmann::json&)> handler);
/// Load and initialize all tools (internal + MCP) /// Load and initialize all tools (internal + MCP)
void initializeTools(); void initializeTools();
/// Load MCP server configurations /// Load MCP server configurations
bool loadMCPConfig(const std::string& configPath); bool loadMCPConfig(const std::string& configPath);
/** /**
* @brief Synchronous response structure for MCP Server mode * @brief Synchronous response structure for MCP Server mode
*/ */
struct SyncResponse { struct SyncResponse {
std::string text; std::string text;
int tokens = 0; int tokens = 0;
int iterations = 0; int iterations = 0;
}; };
/** /**
* @brief Send message synchronously (blocking, for MCP Server mode) * @brief Send message synchronously (blocking, for MCP Server mode)
* *
* @param message User message * @param message User message
* @param conversationId Conversation ID (optional) * @param conversationId Conversation ID (optional)
* @param systemPrompt Custom system prompt (optional) * @param systemPrompt Custom system prompt (optional)
* @return Sync response with text, tokens, iterations * @return Sync response with text, tokens, iterations
*/ */
SyncResponse sendMessageSync( SyncResponse sendMessageSync(
const std::string& message, const std::string& message,
const std::string& conversationId = "", const std::string& conversationId = "",
const std::string& systemPrompt = "" const std::string& systemPrompt = ""
); );
private: private:
struct Request { struct Request {
std::string query; std::string query;
std::string systemPrompt; std::string systemPrompt;
std::string conversationId; std::string conversationId;
nlohmann::json tools; nlohmann::json tools;
int maxIterations = 10; int maxIterations = 10;
}; };
struct Response { struct Response {
std::string text; std::string text;
std::string conversationId; std::string conversationId;
int tokens = 0; int tokens = 0;
int iterations = 0; int iterations = 0;
bool isError = false; bool isError = false;
}; };
// Configuration // Configuration
std::string m_providerName = "claude"; std::string m_providerName = "claude";
std::string m_defaultSystemPrompt; std::string m_defaultSystemPrompt;
int m_maxIterations = 10; int m_maxIterations = 10;
// State // State
std::unique_ptr<ILLMProvider> m_provider; std::unique_ptr<ILLMProvider> m_provider;
ToolRegistry m_toolRegistry; ToolRegistry m_toolRegistry;
std::unique_ptr<InternalTools> m_internalTools; std::unique_ptr<InternalTools> m_internalTools;
std::unique_ptr<mcp::MCPClient> m_mcpClient; std::unique_ptr<mcp::MCPClient> m_mcpClient;
std::map<std::string, nlohmann::json> m_conversations; // conversationId -> history std::map<std::string, nlohmann::json> m_conversations; // conversationId -> history
// Threading // Threading
std::thread m_workerThread; std::thread m_workerThread;
std::atomic<bool> m_running{false}; std::atomic<bool> m_running{false};
std::queue<Request> m_requestQueue; std::queue<Request> m_requestQueue;
std::queue<Response> m_responseQueue; std::queue<Response> m_responseQueue;
std::mutex m_requestMutex; std::mutex m_requestMutex;
std::mutex m_responseMutex; std::mutex m_responseMutex;
std::condition_variable m_requestCV; std::condition_variable m_requestCV;
// Services // Services
grove::IIO* m_io = nullptr; grove::IIO* m_io = nullptr;
std::shared_ptr<spdlog::logger> m_logger; std::shared_ptr<spdlog::logger> m_logger;
// Worker thread // Worker thread
void workerLoop(); void workerLoop();
Response processRequest(const Request& request); Response processRequest(const Request& request);
nlohmann::json agenticLoop(const std::string& query, const std::string& systemPrompt, nlohmann::json agenticLoop(const std::string& query, const std::string& systemPrompt,
nlohmann::json& messages, const nlohmann::json& tools, nlohmann::json& messages, const nlohmann::json& tools,
int maxIterations); int maxIterations);
// Message handling // Message handling
void processIncomingMessages(); void processIncomingMessages();
void publishResponses(); void publishResponses();
}; };
} // namespace aissia } // namespace aissia

View File

@ -1,139 +1,139 @@
#include "PlatformService.hpp" #include "PlatformService.hpp"
#include <spdlog/sinks/stdout_color_sinks.h> #include <spdlog/sinks/stdout_color_sinks.h>
namespace aissia { namespace aissia {
PlatformService::PlatformService() { PlatformService::PlatformService() {
m_logger = spdlog::get("PlatformService"); m_logger = spdlog::get("PlatformService");
if (!m_logger) { if (!m_logger) {
m_logger = spdlog::stdout_color_mt("PlatformService"); m_logger = spdlog::stdout_color_mt("PlatformService");
} }
} }
bool PlatformService::initialize(grove::IIO* io) { bool PlatformService::initialize(grove::IIO* io) {
m_io = io; m_io = io;
// Create platform-specific window tracker // Create platform-specific window tracker
m_tracker = WindowTrackerFactory::create(); m_tracker = WindowTrackerFactory::create();
if (!m_tracker || !m_tracker->isAvailable()) { if (!m_tracker || !m_tracker->isAvailable()) {
m_logger->warn("Window tracker not available on this platform"); m_logger->warn("Window tracker not available on this platform");
return true; // Non-fatal, module can work without tracking return true; // Non-fatal, module can work without tracking
} }
if (m_io) { if (m_io) {
grove::SubscriptionConfig config; grove::SubscriptionConfig config;
m_io->subscribe("platform:query_window", config); m_io->subscribe("platform:query_window", config);
} }
m_logger->info("PlatformService initialized: {}", m_tracker->getPlatformName()); m_logger->info("PlatformService initialized: {}", m_tracker->getPlatformName());
return true; return true;
} }
void PlatformService::configure(int pollIntervalMs, int idleThresholdSeconds) { void PlatformService::configure(int pollIntervalMs, int idleThresholdSeconds) {
m_pollIntervalMs = pollIntervalMs; m_pollIntervalMs = pollIntervalMs;
m_idleThresholdSeconds = idleThresholdSeconds; m_idleThresholdSeconds = idleThresholdSeconds;
m_logger->debug("Configured: poll={}ms, idle={}s", pollIntervalMs, idleThresholdSeconds); m_logger->debug("Configured: poll={}ms, idle={}s", pollIntervalMs, idleThresholdSeconds);
} }
void PlatformService::process() { void PlatformService::process() {
if (!m_tracker || !m_tracker->isAvailable()) return; if (!m_tracker || !m_tracker->isAvailable()) return;
// Use monotonic clock for timing // Use monotonic clock for timing
static auto startTime = std::chrono::steady_clock::now(); static auto startTime = std::chrono::steady_clock::now();
auto now = std::chrono::steady_clock::now(); auto now = std::chrono::steady_clock::now();
float currentTime = std::chrono::duration<float>(now - startTime).count(); float currentTime = std::chrono::duration<float>(now - startTime).count();
float pollIntervalSec = m_pollIntervalMs / 1000.0f; float pollIntervalSec = m_pollIntervalMs / 1000.0f;
if (currentTime - m_lastPollTime >= pollIntervalSec) { if (currentTime - m_lastPollTime >= pollIntervalSec) {
m_lastPollTime = currentTime; m_lastPollTime = currentTime;
pollWindowInfo(currentTime); pollWindowInfo(currentTime);
} }
// Handle query requests // Handle query requests
if (m_io) { if (m_io) {
while (m_io->hasMessages() > 0) { while (m_io->hasMessages() > 0) {
auto msg = m_io->pullMessage(); auto msg = m_io->pullMessage();
if (msg.topic == "platform:query_window") { if (msg.topic == "platform:query_window") {
publishWindowInfo(); publishWindowInfo();
} }
} }
} }
} }
void PlatformService::pollWindowInfo(float currentTime) { void PlatformService::pollWindowInfo(float currentTime) {
std::string newApp = m_tracker->getCurrentAppName(); std::string newApp = m_tracker->getCurrentAppName();
std::string newTitle = m_tracker->getCurrentWindowTitle(); std::string newTitle = m_tracker->getCurrentWindowTitle();
// Check for app change // Check for app change
if (newApp != m_currentApp) { if (newApp != m_currentApp) {
int duration = static_cast<int>(currentTime - m_appStartTime); int duration = static_cast<int>(currentTime - m_appStartTime);
if (!m_currentApp.empty() && duration > 0) { if (!m_currentApp.empty() && duration > 0) {
publishWindowChanged(m_currentApp, newApp, duration); publishWindowChanged(m_currentApp, newApp, duration);
} }
m_currentApp = newApp; m_currentApp = newApp;
m_currentWindowTitle = newTitle; m_currentWindowTitle = newTitle;
m_appStartTime = currentTime; m_appStartTime = currentTime;
m_logger->debug("App: {} - {}", m_currentApp, m_logger->debug("App: {} - {}", m_currentApp,
m_currentWindowTitle.size() > 50 ? m_currentWindowTitle.size() > 50 ?
m_currentWindowTitle.substr(0, 50) + "..." : m_currentWindowTitle); m_currentWindowTitle.substr(0, 50) + "..." : m_currentWindowTitle);
} }
// Check idle state // Check idle state
bool isIdle = m_tracker->isUserIdle(m_idleThresholdSeconds); bool isIdle = m_tracker->isUserIdle(m_idleThresholdSeconds);
if (isIdle && !m_wasIdle) { if (isIdle && !m_wasIdle) {
m_logger->info("User idle detected ({}s)", m_idleThresholdSeconds); m_logger->info("User idle detected ({}s)", m_idleThresholdSeconds);
if (m_io) { if (m_io) {
auto event = std::make_unique<grove::JsonDataNode>("idle"); auto event = std::make_unique<grove::JsonDataNode>("idle");
event->setInt("idleSeconds", m_tracker->getIdleTimeSeconds()); event->setInt("idleSeconds", m_tracker->getIdleTimeSeconds());
m_io->publish("platform:idle_detected", std::move(event)); m_io->publish("platform:idle_detected", std::move(event));
} }
} }
else if (!isIdle && m_wasIdle) { else if (!isIdle && m_wasIdle) {
m_logger->info("User activity resumed"); m_logger->info("User activity resumed");
if (m_io) { if (m_io) {
auto event = std::make_unique<grove::JsonDataNode>("active"); auto event = std::make_unique<grove::JsonDataNode>("active");
m_io->publish("platform:activity_resumed", std::move(event)); m_io->publish("platform:activity_resumed", std::move(event));
} }
} }
m_wasIdle = isIdle; m_wasIdle = isIdle;
// Publish periodic window info // Publish periodic window info
publishWindowInfo(); publishWindowInfo();
} }
void PlatformService::publishWindowInfo() { void PlatformService::publishWindowInfo() {
if (!m_io || !m_tracker) return; if (!m_io || !m_tracker) return;
auto event = std::make_unique<grove::JsonDataNode>("window"); auto event = std::make_unique<grove::JsonDataNode>("window");
event->setString("appName", m_currentApp); event->setString("appName", m_currentApp);
event->setString("windowTitle", m_currentWindowTitle); event->setString("windowTitle", m_currentWindowTitle);
event->setBool("isIdle", m_wasIdle); event->setBool("isIdle", m_wasIdle);
event->setInt("idleSeconds", m_tracker->getIdleTimeSeconds()); event->setInt("idleSeconds", m_tracker->getIdleTimeSeconds());
m_io->publish("platform:window_info", std::move(event)); m_io->publish("platform:window_info", std::move(event));
} }
void PlatformService::publishWindowChanged(const std::string& oldApp, void PlatformService::publishWindowChanged(const std::string& oldApp,
const std::string& newApp, const std::string& newApp,
int duration) { int duration) {
if (!m_io) return; if (!m_io) return;
auto event = std::make_unique<grove::JsonDataNode>("changed"); auto event = std::make_unique<grove::JsonDataNode>("changed");
event->setString("oldApp", oldApp); event->setString("oldApp", oldApp);
event->setString("newApp", newApp); event->setString("newApp", newApp);
event->setInt("duration", duration); event->setInt("duration", duration);
m_io->publish("platform:window_changed", std::move(event)); m_io->publish("platform:window_changed", std::move(event));
} }
void PlatformService::shutdown() { void PlatformService::shutdown() {
m_tracker.reset(); m_tracker.reset();
m_logger->info("PlatformService shutdown"); m_logger->info("PlatformService shutdown");
} }
} // namespace aissia } // namespace aissia

View File

@ -1,67 +1,67 @@
#pragma once #pragma once
#include "IService.hpp" #include "IService.hpp"
#include "../shared/platform/IWindowTracker.hpp" #include "../shared/platform/IWindowTracker.hpp"
#include <grove/IIO.h> #include <grove/IIO.h>
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <memory> #include <memory>
#include <string> #include <string>
namespace aissia { namespace aissia {
/** /**
* @brief Platform Service - OS-specific APIs (window tracking, etc.) * @brief Platform Service - OS-specific APIs (window tracking, etc.)
* *
* Handles platform-specific operations that can't be in hot-reload modules. * Handles platform-specific operations that can't be in hot-reload modules.
* Polls foreground window at configurable interval. * Polls foreground window at configurable interval.
* *
* Subscribes to: * Subscribes to:
* - "platform:query_window" : Request current window info * - "platform:query_window" : Request current window info
* *
* Publishes: * Publishes:
* - "platform:window_info" : { appName, windowTitle, isIdle, idleSeconds } * - "platform:window_info" : { appName, windowTitle, isIdle, idleSeconds }
* - "platform:window_changed" : { oldApp, newApp, duration } * - "platform:window_changed" : { oldApp, newApp, duration }
* - "platform:idle_detected" : { idleSeconds } * - "platform:idle_detected" : { idleSeconds }
* - "platform:activity_resumed" : {} * - "platform:activity_resumed" : {}
*/ */
class PlatformService : public IService { class PlatformService : public IService {
public: public:
PlatformService(); PlatformService();
~PlatformService() override = default; ~PlatformService() override = default;
bool initialize(grove::IIO* io) override; bool initialize(grove::IIO* io) override;
void process() override; void process() override;
void shutdown() override; void shutdown() override;
std::string getName() const override { return "PlatformService"; } std::string getName() const override { return "PlatformService"; }
bool isHealthy() const override { return m_tracker != nullptr && m_tracker->isAvailable(); } bool isHealthy() const override { return m_tracker != nullptr && m_tracker->isAvailable(); }
/// Configure polling interval and idle threshold /// Configure polling interval and idle threshold
void configure(int pollIntervalMs = 1000, int idleThresholdSeconds = 300); void configure(int pollIntervalMs = 1000, int idleThresholdSeconds = 300);
private: private:
// Configuration // Configuration
int m_pollIntervalMs = 1000; int m_pollIntervalMs = 1000;
int m_idleThresholdSeconds = 300; int m_idleThresholdSeconds = 300;
// State // State
std::unique_ptr<IWindowTracker> m_tracker; std::unique_ptr<IWindowTracker> m_tracker;
std::string m_currentApp; std::string m_currentApp;
std::string m_currentWindowTitle; std::string m_currentWindowTitle;
float m_appStartTime = 0.0f; float m_appStartTime = 0.0f;
float m_lastPollTime = 0.0f; float m_lastPollTime = 0.0f;
bool m_wasIdle = false; bool m_wasIdle = false;
// Services // Services
grove::IIO* m_io = nullptr; grove::IIO* m_io = nullptr;
std::shared_ptr<spdlog::logger> m_logger; std::shared_ptr<spdlog::logger> m_logger;
// Helpers // Helpers
void pollWindowInfo(float currentTime); void pollWindowInfo(float currentTime);
void publishWindowInfo(); void publishWindowInfo();
void publishWindowChanged(const std::string& oldApp, const std::string& newApp, int duration); void publishWindowChanged(const std::string& oldApp, const std::string& newApp, int duration);
}; };
} // namespace aissia } // namespace aissia

View File

@ -1,189 +1,189 @@
// CRITICAL ORDER: Include system headers first // CRITICAL ORDER: Include system headers first
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <cstdlib> #include <cstdlib>
#include <memory> #include <memory>
#include <string> #include <string>
// Include local headers before spdlog // Include local headers before spdlog
#include "STTService.hpp" #include "STTService.hpp"
#include "../shared/audio/ISTTEngine.hpp" #include "../shared/audio/ISTTEngine.hpp"
// Include spdlog after local headers // Include spdlog after local headers
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <spdlog/sinks/stdout_color_sinks.h> #include <spdlog/sinks/stdout_color_sinks.h>
namespace aissia { namespace aissia {
STTService::STTService(const nlohmann::json& config) STTService::STTService(const nlohmann::json& config)
: m_config(config) : m_config(config)
{ {
m_logger = spdlog::get("STTService"); m_logger = spdlog::get("STTService");
if (!m_logger) { if (!m_logger) {
m_logger = spdlog::stdout_color_mt("STTService"); m_logger = spdlog::stdout_color_mt("STTService");
} }
// Extract language from config // Extract language from config
if (config.contains("active_mode") && config["active_mode"].contains("language")) { if (config.contains("active_mode") && config["active_mode"].contains("language")) {
m_language = config["active_mode"]["language"].get<std::string>(); m_language = config["active_mode"]["language"].get<std::string>();
} }
m_logger->info("STTService created"); m_logger->info("STTService created");
} }
STTService::~STTService() { STTService::~STTService() {
stop(); stop();
} }
bool STTService::start() { bool STTService::start() {
m_logger->info("Starting STT service"); m_logger->info("Starting STT service");
loadEngines(); loadEngines();
if (!m_activeEngine || !m_activeEngine->isAvailable()) { if (!m_activeEngine || !m_activeEngine->isAvailable()) {
m_logger->error("No active STT engine available"); m_logger->error("No active STT engine available");
return false; return false;
} }
m_logger->info("STT service started"); m_logger->info("STT service started");
return true; return true;
} }
void STTService::stop() { void STTService::stop() {
m_logger->info("Stopping STT service"); m_logger->info("Stopping STT service");
stopListening(); stopListening();
m_activeEngine.reset(); m_activeEngine.reset();
} }
void STTService::setMode(STTMode mode) { void STTService::setMode(STTMode mode) {
if (m_currentMode == mode) { if (m_currentMode == mode) {
return; return;
} }
m_logger->info("Switching STT mode"); m_logger->info("Switching STT mode");
m_currentMode = mode; m_currentMode = mode;
} }
std::string STTService::transcribeFile(const std::string& filePath) { std::string STTService::transcribeFile(const std::string& filePath) {
if (!m_activeEngine || !m_activeEngine->isAvailable()) { if (!m_activeEngine || !m_activeEngine->isAvailable()) {
m_logger->warn("No STT engine available for transcription"); m_logger->warn("No STT engine available for transcription");
return ""; return "";
} }
m_logger->info("Transcribing file"); m_logger->info("Transcribing file");
try { try {
std::string result = m_activeEngine->transcribeFile(filePath); std::string result = m_activeEngine->transcribeFile(filePath);
m_logger->info("Transcription complete"); m_logger->info("Transcription complete");
return result; return result;
} catch (const std::exception& e) { } catch (const std::exception& e) {
m_logger->error("Transcription failed"); m_logger->error("Transcription failed");
return ""; return "";
} }
} }
std::string STTService::transcribe(const std::vector<float>& audioData) { std::string STTService::transcribe(const std::vector<float>& audioData) {
if (!m_activeEngine || !m_activeEngine->isAvailable()) { if (!m_activeEngine || !m_activeEngine->isAvailable()) {
return ""; return "";
} }
if (audioData.empty()) { if (audioData.empty()) {
return ""; return "";
} }
try { try {
std::string result = m_activeEngine->transcribe(audioData); std::string result = m_activeEngine->transcribe(audioData);
if (!result.empty() && m_listening && m_onTranscription) { if (!result.empty() && m_listening && m_onTranscription) {
m_onTranscription(result, m_currentMode); m_onTranscription(result, m_currentMode);
} }
return result; return result;
} catch (const std::exception& e) { } catch (const std::exception& e) {
m_logger->error("Transcription failed"); m_logger->error("Transcription failed");
return ""; return "";
} }
} }
void STTService::startListening(TranscriptionCallback onTranscription, void STTService::startListening(TranscriptionCallback onTranscription,
KeywordCallback onKeyword) { KeywordCallback onKeyword) {
m_logger->info("Start listening"); m_logger->info("Start listening");
m_onTranscription = onTranscription; m_onTranscription = onTranscription;
m_onKeyword = onKeyword; m_onKeyword = onKeyword;
m_listening = true; m_listening = true;
m_logger->warn("Streaming microphone capture not yet implemented"); m_logger->warn("Streaming microphone capture not yet implemented");
} }
void STTService::stopListening() { void STTService::stopListening() {
if (!m_listening) { if (!m_listening) {
return; return;
} }
m_logger->info("Stop listening"); m_logger->info("Stop listening");
m_listening = false; m_listening = false;
} }
void STTService::setLanguage(const std::string& language) { void STTService::setLanguage(const std::string& language) {
m_logger->info("Setting language"); m_logger->info("Setting language");
m_language = language; m_language = language;
if (m_activeEngine) { if (m_activeEngine) {
m_activeEngine->setLanguage(language); m_activeEngine->setLanguage(language);
} }
} }
bool STTService::isAvailable() const { bool STTService::isAvailable() const {
return m_activeEngine && m_activeEngine->isAvailable(); return m_activeEngine && m_activeEngine->isAvailable();
} }
std::string STTService::getCurrentEngine() const { std::string STTService::getCurrentEngine() const {
if (m_activeEngine) { if (m_activeEngine) {
return m_activeEngine->getEngineName(); return m_activeEngine->getEngineName();
} }
return "none"; return "none";
} }
void STTService::loadEngines() { void STTService::loadEngines() {
m_logger->info("Loading STT engines"); m_logger->info("Loading STT engines");
std::string engineType = "auto"; std::string engineType = "auto";
if (m_config.contains("active_mode")) { if (m_config.contains("active_mode")) {
const auto& activeMode = m_config["active_mode"]; const auto& activeMode = m_config["active_mode"];
if (activeMode.contains("engine")) { if (activeMode.contains("engine")) {
engineType = activeMode["engine"]; engineType = activeMode["engine"];
} }
} }
std::string modelPath; std::string modelPath;
if (m_config.contains("active_mode")) { if (m_config.contains("active_mode")) {
const auto& activeMode = m_config["active_mode"]; const auto& activeMode = m_config["active_mode"];
if (activeMode.contains("model_path")) { if (activeMode.contains("model_path")) {
modelPath = activeMode["model_path"]; modelPath = activeMode["model_path"];
} }
} }
std::string apiKey; std::string apiKey;
if (m_config.contains("whisper_api")) { if (m_config.contains("whisper_api")) {
const auto& whisperApi = m_config["whisper_api"]; const auto& whisperApi = m_config["whisper_api"];
std::string apiKeyEnv = "OPENAI_API_KEY"; std::string apiKeyEnv = "OPENAI_API_KEY";
if (whisperApi.contains("api_key_env")) { if (whisperApi.contains("api_key_env")) {
apiKeyEnv = whisperApi["api_key_env"]; apiKeyEnv = whisperApi["api_key_env"];
} }
const char* envVal = std::getenv(apiKeyEnv.c_str()); const char* envVal = std::getenv(apiKeyEnv.c_str());
if (envVal) { if (envVal) {
apiKey = envVal; apiKey = envVal;
} }
} }
m_activeEngine = STTEngineFactory::create(engineType, modelPath, apiKey); m_activeEngine = STTEngineFactory::create(engineType, modelPath, apiKey);
if (m_activeEngine && m_activeEngine->isAvailable()) { if (m_activeEngine && m_activeEngine->isAvailable()) {
m_activeEngine->setLanguage(m_language); m_activeEngine->setLanguage(m_language);
m_logger->info("STT engine loaded successfully"); m_logger->info("STT engine loaded successfully");
} else { } else {
m_logger->warn("No active STT engine available"); m_logger->warn("No active STT engine available");
} }
} }
} // namespace aissia } // namespace aissia

View File

@ -1,348 +1,348 @@
#include "StorageService.hpp" #include "StorageService.hpp"
#include <spdlog/sinks/stdout_color_sinks.h> #include <spdlog/sinks/stdout_color_sinks.h>
#include <sqlite3.h> #include <sqlite3.h>
#include <filesystem> #include <filesystem>
#include <ctime> #include <ctime>
namespace fs = std::filesystem; namespace fs = std::filesystem;
namespace aissia { namespace aissia {
StorageService::StorageService() { StorageService::StorageService() {
m_logger = spdlog::get("StorageService"); m_logger = spdlog::get("StorageService");
if (!m_logger) { if (!m_logger) {
m_logger = spdlog::stdout_color_mt("StorageService"); m_logger = spdlog::stdout_color_mt("StorageService");
} }
} }
StorageService::~StorageService() { StorageService::~StorageService() {
shutdown(); shutdown();
} }
bool StorageService::initialize(grove::IIO* io) { bool StorageService::initialize(grove::IIO* io) {
m_io = io; m_io = io;
if (m_io) { if (m_io) {
grove::SubscriptionConfig config; grove::SubscriptionConfig config;
m_io->subscribe("storage:save_session", config); m_io->subscribe("storage:save_session", config);
m_io->subscribe("storage:save_app_usage", config); m_io->subscribe("storage:save_app_usage", config);
m_io->subscribe("storage:save_conversation", config); m_io->subscribe("storage:save_conversation", config);
m_io->subscribe("storage:update_metrics", config); m_io->subscribe("storage:update_metrics", config);
} }
m_logger->info("StorageService initialized"); m_logger->info("StorageService initialized");
return true; return true;
} }
bool StorageService::openDatabase(const std::string& dbPath, bool StorageService::openDatabase(const std::string& dbPath,
const std::string& journalMode, const std::string& journalMode,
int busyTimeoutMs) { int busyTimeoutMs) {
m_dbPath = dbPath; m_dbPath = dbPath;
// Ensure directory exists // Ensure directory exists
fs::path path(dbPath); fs::path path(dbPath);
if (path.has_parent_path()) { if (path.has_parent_path()) {
fs::create_directories(path.parent_path()); fs::create_directories(path.parent_path());
} }
int rc = sqlite3_open(dbPath.c_str(), &m_db); int rc = sqlite3_open(dbPath.c_str(), &m_db);
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
m_logger->error("SQLite open error: {}", sqlite3_errmsg(m_db)); m_logger->error("SQLite open error: {}", sqlite3_errmsg(m_db));
return false; return false;
} }
// Set pragmas // Set pragmas
std::string pragmas = "PRAGMA journal_mode=" + journalMode + ";" std::string pragmas = "PRAGMA journal_mode=" + journalMode + ";"
"PRAGMA busy_timeout=" + std::to_string(busyTimeoutMs) + ";" "PRAGMA busy_timeout=" + std::to_string(busyTimeoutMs) + ";"
"PRAGMA foreign_keys=ON;"; "PRAGMA foreign_keys=ON;";
if (!executeSQL(pragmas)) { if (!executeSQL(pragmas)) {
return false; return false;
} }
if (!initializeSchema()) { if (!initializeSchema()) {
return false; return false;
} }
if (!prepareStatements()) { if (!prepareStatements()) {
return false; return false;
} }
m_isConnected = true; m_isConnected = true;
// Publish ready event // Publish ready event
if (m_io) { if (m_io) {
auto event = std::make_unique<grove::JsonDataNode>("ready"); auto event = std::make_unique<grove::JsonDataNode>("ready");
event->setString("database", dbPath); event->setString("database", dbPath);
m_io->publish("storage:ready", std::move(event)); m_io->publish("storage:ready", std::move(event));
} }
m_logger->info("Database opened: {}", dbPath); m_logger->info("Database opened: {}", dbPath);
return true; return true;
} }
bool StorageService::initializeSchema() { bool StorageService::initializeSchema() {
const char* schema = R"SQL( const char* schema = R"SQL(
CREATE TABLE IF NOT EXISTS work_sessions ( CREATE TABLE IF NOT EXISTS work_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
task_name TEXT, task_name TEXT,
start_time INTEGER, start_time INTEGER,
end_time INTEGER, end_time INTEGER,
duration_minutes INTEGER, duration_minutes INTEGER,
hyperfocus_detected BOOLEAN DEFAULT 0, hyperfocus_detected BOOLEAN DEFAULT 0,
created_at INTEGER DEFAULT (strftime('%s', 'now')) created_at INTEGER DEFAULT (strftime('%s', 'now'))
); );
CREATE TABLE IF NOT EXISTS app_usage ( CREATE TABLE IF NOT EXISTS app_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER, session_id INTEGER,
app_name TEXT, app_name TEXT,
duration_seconds INTEGER, duration_seconds INTEGER,
is_productive BOOLEAN, is_productive BOOLEAN,
created_at INTEGER DEFAULT (strftime('%s', 'now')), created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (session_id) REFERENCES work_sessions(id) FOREIGN KEY (session_id) REFERENCES work_sessions(id)
); );
CREATE TABLE IF NOT EXISTS conversations ( CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT, role TEXT,
content TEXT, content TEXT,
provider TEXT, provider TEXT,
model TEXT, model TEXT,
tokens_used INTEGER, tokens_used INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')) created_at INTEGER DEFAULT (strftime('%s', 'now'))
); );
CREATE TABLE IF NOT EXISTS daily_metrics ( CREATE TABLE IF NOT EXISTS daily_metrics (
date TEXT PRIMARY KEY, date TEXT PRIMARY KEY,
total_focus_minutes INTEGER DEFAULT 0, total_focus_minutes INTEGER DEFAULT 0,
total_breaks INTEGER DEFAULT 0, total_breaks INTEGER DEFAULT 0,
hyperfocus_count INTEGER DEFAULT 0, hyperfocus_count INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT (strftime('%s', 'now')) updated_at INTEGER DEFAULT (strftime('%s', 'now'))
); );
CREATE INDEX IF NOT EXISTS idx_sessions_date ON work_sessions(created_at); CREATE INDEX IF NOT EXISTS idx_sessions_date ON work_sessions(created_at);
CREATE INDEX IF NOT EXISTS idx_app_usage_session ON app_usage(session_id); CREATE INDEX IF NOT EXISTS idx_app_usage_session ON app_usage(session_id);
CREATE INDEX IF NOT EXISTS idx_conversations_date ON conversations(created_at); CREATE INDEX IF NOT EXISTS idx_conversations_date ON conversations(created_at);
)SQL"; )SQL";
return executeSQL(schema); return executeSQL(schema);
} }
bool StorageService::prepareStatements() { bool StorageService::prepareStatements() {
int rc; int rc;
// Save session statement // Save session statement
const char* sqlSession = "INSERT INTO work_sessions " const char* sqlSession = "INSERT INTO work_sessions "
"(task_name, start_time, end_time, duration_minutes, hyperfocus_detected) " "(task_name, start_time, end_time, duration_minutes, hyperfocus_detected) "
"VALUES (?, ?, ?, ?, ?)"; "VALUES (?, ?, ?, ?, ?)";
rc = sqlite3_prepare_v2(m_db, sqlSession, -1, &m_stmtSaveSession, nullptr); rc = sqlite3_prepare_v2(m_db, sqlSession, -1, &m_stmtSaveSession, nullptr);
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
m_logger->error("Failed to prepare save_session: {}", sqlite3_errmsg(m_db)); m_logger->error("Failed to prepare save_session: {}", sqlite3_errmsg(m_db));
return false; return false;
} }
// Save app usage statement // Save app usage statement
const char* sqlAppUsage = "INSERT INTO app_usage " const char* sqlAppUsage = "INSERT INTO app_usage "
"(session_id, app_name, duration_seconds, is_productive) " "(session_id, app_name, duration_seconds, is_productive) "
"VALUES (?, ?, ?, ?)"; "VALUES (?, ?, ?, ?)";
rc = sqlite3_prepare_v2(m_db, sqlAppUsage, -1, &m_stmtSaveAppUsage, nullptr); rc = sqlite3_prepare_v2(m_db, sqlAppUsage, -1, &m_stmtSaveAppUsage, nullptr);
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
m_logger->error("Failed to prepare save_app_usage: {}", sqlite3_errmsg(m_db)); m_logger->error("Failed to prepare save_app_usage: {}", sqlite3_errmsg(m_db));
return false; return false;
} }
// Save conversation statement // Save conversation statement
const char* sqlConv = "INSERT INTO conversations " const char* sqlConv = "INSERT INTO conversations "
"(role, content, provider, model, tokens_used) " "(role, content, provider, model, tokens_used) "
"VALUES (?, ?, ?, ?, ?)"; "VALUES (?, ?, ?, ?, ?)";
rc = sqlite3_prepare_v2(m_db, sqlConv, -1, &m_stmtSaveConversation, nullptr); rc = sqlite3_prepare_v2(m_db, sqlConv, -1, &m_stmtSaveConversation, nullptr);
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
m_logger->error("Failed to prepare save_conversation: {}", sqlite3_errmsg(m_db)); m_logger->error("Failed to prepare save_conversation: {}", sqlite3_errmsg(m_db));
return false; return false;
} }
// Update metrics statement // Update metrics statement
const char* sqlMetrics = "INSERT INTO daily_metrics " const char* sqlMetrics = "INSERT INTO daily_metrics "
"(date, total_focus_minutes, total_breaks, hyperfocus_count) " "(date, total_focus_minutes, total_breaks, hyperfocus_count) "
"VALUES (?, ?, ?, ?) " "VALUES (?, ?, ?, ?) "
"ON CONFLICT(date) DO UPDATE SET " "ON CONFLICT(date) DO UPDATE SET "
"total_focus_minutes = total_focus_minutes + excluded.total_focus_minutes, " "total_focus_minutes = total_focus_minutes + excluded.total_focus_minutes, "
"total_breaks = total_breaks + excluded.total_breaks, " "total_breaks = total_breaks + excluded.total_breaks, "
"hyperfocus_count = hyperfocus_count + excluded.hyperfocus_count, " "hyperfocus_count = hyperfocus_count + excluded.hyperfocus_count, "
"updated_at = strftime('%s', 'now')"; "updated_at = strftime('%s', 'now')";
rc = sqlite3_prepare_v2(m_db, sqlMetrics, -1, &m_stmtUpdateMetrics, nullptr); rc = sqlite3_prepare_v2(m_db, sqlMetrics, -1, &m_stmtUpdateMetrics, nullptr);
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
m_logger->error("Failed to prepare update_metrics: {}", sqlite3_errmsg(m_db)); m_logger->error("Failed to prepare update_metrics: {}", sqlite3_errmsg(m_db));
return false; return false;
} }
m_logger->debug("Prepared statements created"); m_logger->debug("Prepared statements created");
return true; return true;
} }
void StorageService::finalizeStatements() { void StorageService::finalizeStatements() {
if (m_stmtSaveSession) { sqlite3_finalize(m_stmtSaveSession); m_stmtSaveSession = nullptr; } if (m_stmtSaveSession) { sqlite3_finalize(m_stmtSaveSession); m_stmtSaveSession = nullptr; }
if (m_stmtSaveAppUsage) { sqlite3_finalize(m_stmtSaveAppUsage); m_stmtSaveAppUsage = nullptr; } if (m_stmtSaveAppUsage) { sqlite3_finalize(m_stmtSaveAppUsage); m_stmtSaveAppUsage = nullptr; }
if (m_stmtSaveConversation) { sqlite3_finalize(m_stmtSaveConversation); m_stmtSaveConversation = nullptr; } if (m_stmtSaveConversation) { sqlite3_finalize(m_stmtSaveConversation); m_stmtSaveConversation = nullptr; }
if (m_stmtUpdateMetrics) { sqlite3_finalize(m_stmtUpdateMetrics); m_stmtUpdateMetrics = nullptr; } if (m_stmtUpdateMetrics) { sqlite3_finalize(m_stmtUpdateMetrics); m_stmtUpdateMetrics = nullptr; }
} }
void StorageService::process() { void StorageService::process() {
processMessages(); processMessages();
} }
void StorageService::processMessages() { void StorageService::processMessages() {
if (!m_io || !m_isConnected) return; if (!m_io || !m_isConnected) return;
while (m_io->hasMessages() > 0) { while (m_io->hasMessages() > 0) {
auto msg = m_io->pullMessage(); auto msg = m_io->pullMessage();
if (msg.topic == "storage:save_session" && msg.data) { if (msg.topic == "storage:save_session" && msg.data) {
handleSaveSession(*msg.data); handleSaveSession(*msg.data);
} }
else if (msg.topic == "storage:save_app_usage" && msg.data) { else if (msg.topic == "storage:save_app_usage" && msg.data) {
handleSaveAppUsage(*msg.data); handleSaveAppUsage(*msg.data);
} }
else if (msg.topic == "storage:save_conversation" && msg.data) { else if (msg.topic == "storage:save_conversation" && msg.data) {
handleSaveConversation(*msg.data); handleSaveConversation(*msg.data);
} }
else if (msg.topic == "storage:update_metrics" && msg.data) { else if (msg.topic == "storage:update_metrics" && msg.data) {
handleUpdateMetrics(*msg.data); handleUpdateMetrics(*msg.data);
} }
} }
} }
void StorageService::handleSaveSession(const grove::IDataNode& data) { void StorageService::handleSaveSession(const grove::IDataNode& data) {
std::string taskName = data.getString("taskName", "unknown"); std::string taskName = data.getString("taskName", "unknown");
int durationMinutes = data.getInt("durationMinutes", 0); int durationMinutes = data.getInt("durationMinutes", 0);
bool hyperfocus = data.getBool("hyperfocus", false); bool hyperfocus = data.getBool("hyperfocus", false);
std::time_t now = std::time(nullptr); std::time_t now = std::time(nullptr);
std::time_t startTime = now - (durationMinutes * 60); std::time_t startTime = now - (durationMinutes * 60);
sqlite3_reset(m_stmtSaveSession); sqlite3_reset(m_stmtSaveSession);
sqlite3_bind_text(m_stmtSaveSession, 1, taskName.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(m_stmtSaveSession, 1, taskName.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(m_stmtSaveSession, 2, startTime); sqlite3_bind_int64(m_stmtSaveSession, 2, startTime);
sqlite3_bind_int64(m_stmtSaveSession, 3, now); sqlite3_bind_int64(m_stmtSaveSession, 3, now);
sqlite3_bind_int(m_stmtSaveSession, 4, durationMinutes); sqlite3_bind_int(m_stmtSaveSession, 4, durationMinutes);
sqlite3_bind_int(m_stmtSaveSession, 5, hyperfocus ? 1 : 0); sqlite3_bind_int(m_stmtSaveSession, 5, hyperfocus ? 1 : 0);
int rc = sqlite3_step(m_stmtSaveSession); int rc = sqlite3_step(m_stmtSaveSession);
if (rc == SQLITE_DONE) { if (rc == SQLITE_DONE) {
m_lastSessionId = static_cast<int>(sqlite3_last_insert_rowid(m_db)); m_lastSessionId = static_cast<int>(sqlite3_last_insert_rowid(m_db));
m_totalQueries++; m_totalQueries++;
m_logger->debug("Session saved: {} ({}min), id={}", taskName, durationMinutes, m_lastSessionId); m_logger->debug("Session saved: {} ({}min), id={}", taskName, durationMinutes, m_lastSessionId);
if (m_io) { if (m_io) {
auto event = std::make_unique<grove::JsonDataNode>("saved"); auto event = std::make_unique<grove::JsonDataNode>("saved");
event->setInt("sessionId", m_lastSessionId); event->setInt("sessionId", m_lastSessionId);
m_io->publish("storage:session_saved", std::move(event)); m_io->publish("storage:session_saved", std::move(event));
} }
} else { } else {
publishError(sqlite3_errmsg(m_db)); publishError(sqlite3_errmsg(m_db));
} }
} }
void StorageService::handleSaveAppUsage(const grove::IDataNode& data) { void StorageService::handleSaveAppUsage(const grove::IDataNode& data) {
int sessionId = data.getInt("sessionId", m_lastSessionId); int sessionId = data.getInt("sessionId", m_lastSessionId);
std::string appName = data.getString("appName", ""); std::string appName = data.getString("appName", "");
int durationSeconds = data.getInt("durationSeconds", 0); int durationSeconds = data.getInt("durationSeconds", 0);
bool productive = data.getBool("productive", false); bool productive = data.getBool("productive", false);
sqlite3_reset(m_stmtSaveAppUsage); sqlite3_reset(m_stmtSaveAppUsage);
sqlite3_bind_int(m_stmtSaveAppUsage, 1, sessionId); sqlite3_bind_int(m_stmtSaveAppUsage, 1, sessionId);
sqlite3_bind_text(m_stmtSaveAppUsage, 2, appName.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(m_stmtSaveAppUsage, 2, appName.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(m_stmtSaveAppUsage, 3, durationSeconds); sqlite3_bind_int(m_stmtSaveAppUsage, 3, durationSeconds);
sqlite3_bind_int(m_stmtSaveAppUsage, 4, productive ? 1 : 0); sqlite3_bind_int(m_stmtSaveAppUsage, 4, productive ? 1 : 0);
int rc = sqlite3_step(m_stmtSaveAppUsage); int rc = sqlite3_step(m_stmtSaveAppUsage);
if (rc == SQLITE_DONE) { if (rc == SQLITE_DONE) {
m_totalQueries++; m_totalQueries++;
} else { } else {
publishError(sqlite3_errmsg(m_db)); publishError(sqlite3_errmsg(m_db));
} }
} }
void StorageService::handleSaveConversation(const grove::IDataNode& data) { void StorageService::handleSaveConversation(const grove::IDataNode& data) {
std::string role = data.getString("role", ""); std::string role = data.getString("role", "");
std::string content = data.getString("content", ""); std::string content = data.getString("content", "");
std::string provider = data.getString("provider", ""); std::string provider = data.getString("provider", "");
std::string model = data.getString("model", ""); std::string model = data.getString("model", "");
int tokens = data.getInt("tokens", 0); int tokens = data.getInt("tokens", 0);
sqlite3_reset(m_stmtSaveConversation); sqlite3_reset(m_stmtSaveConversation);
sqlite3_bind_text(m_stmtSaveConversation, 1, role.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(m_stmtSaveConversation, 1, role.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(m_stmtSaveConversation, 2, content.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(m_stmtSaveConversation, 2, content.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(m_stmtSaveConversation, 3, provider.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(m_stmtSaveConversation, 3, provider.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(m_stmtSaveConversation, 4, model.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(m_stmtSaveConversation, 4, model.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(m_stmtSaveConversation, 5, tokens); sqlite3_bind_int(m_stmtSaveConversation, 5, tokens);
int rc = sqlite3_step(m_stmtSaveConversation); int rc = sqlite3_step(m_stmtSaveConversation);
if (rc == SQLITE_DONE) { if (rc == SQLITE_DONE) {
m_totalQueries++; m_totalQueries++;
} else { } else {
publishError(sqlite3_errmsg(m_db)); publishError(sqlite3_errmsg(m_db));
} }
} }
void StorageService::handleUpdateMetrics(const grove::IDataNode& data) { void StorageService::handleUpdateMetrics(const grove::IDataNode& data) {
int focusMinutes = data.getInt("focusMinutes", 0); int focusMinutes = data.getInt("focusMinutes", 0);
int breaks = data.getInt("breaks", 0); int breaks = data.getInt("breaks", 0);
int hyperfocusCount = data.getInt("hyperfocusCount", 0); int hyperfocusCount = data.getInt("hyperfocusCount", 0);
std::time_t now = std::time(nullptr); std::time_t now = std::time(nullptr);
std::tm* tm = std::localtime(&now); std::tm* tm = std::localtime(&now);
char dateStr[11]; char dateStr[11];
std::strftime(dateStr, sizeof(dateStr), "%Y-%m-%d", tm); std::strftime(dateStr, sizeof(dateStr), "%Y-%m-%d", tm);
sqlite3_reset(m_stmtUpdateMetrics); sqlite3_reset(m_stmtUpdateMetrics);
sqlite3_bind_text(m_stmtUpdateMetrics, 1, dateStr, -1, SQLITE_TRANSIENT); sqlite3_bind_text(m_stmtUpdateMetrics, 1, dateStr, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(m_stmtUpdateMetrics, 2, focusMinutes); sqlite3_bind_int(m_stmtUpdateMetrics, 2, focusMinutes);
sqlite3_bind_int(m_stmtUpdateMetrics, 3, breaks); sqlite3_bind_int(m_stmtUpdateMetrics, 3, breaks);
sqlite3_bind_int(m_stmtUpdateMetrics, 4, hyperfocusCount); sqlite3_bind_int(m_stmtUpdateMetrics, 4, hyperfocusCount);
int rc = sqlite3_step(m_stmtUpdateMetrics); int rc = sqlite3_step(m_stmtUpdateMetrics);
if (rc == SQLITE_DONE) { if (rc == SQLITE_DONE) {
m_totalQueries++; m_totalQueries++;
} else { } else {
publishError(sqlite3_errmsg(m_db)); publishError(sqlite3_errmsg(m_db));
} }
} }
bool StorageService::executeSQL(const std::string& sql) { bool StorageService::executeSQL(const std::string& sql) {
char* errMsg = nullptr; char* errMsg = nullptr;
int rc = sqlite3_exec(m_db, sql.c_str(), nullptr, nullptr, &errMsg); int rc = sqlite3_exec(m_db, sql.c_str(), nullptr, nullptr, &errMsg);
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
m_logger->error("SQL error: {}", errMsg ? errMsg : "unknown"); m_logger->error("SQL error: {}", errMsg ? errMsg : "unknown");
sqlite3_free(errMsg); sqlite3_free(errMsg);
return false; return false;
} }
m_totalQueries++; m_totalQueries++;
return true; return true;
} }
void StorageService::publishError(const std::string& message) { void StorageService::publishError(const std::string& message) {
m_logger->error("Storage error: {}", message); m_logger->error("Storage error: {}", message);
if (m_io) { if (m_io) {
auto event = std::make_unique<grove::JsonDataNode>("error"); auto event = std::make_unique<grove::JsonDataNode>("error");
event->setString("message", message); event->setString("message", message);
m_io->publish("storage:error", std::move(event)); m_io->publish("storage:error", std::move(event));
} }
} }
void StorageService::shutdown() { void StorageService::shutdown() {
finalizeStatements(); finalizeStatements();
if (m_db) { if (m_db) {
sqlite3_close(m_db); sqlite3_close(m_db);
m_db = nullptr; m_db = nullptr;
m_isConnected = false; m_isConnected = false;
} }
m_logger->info("StorageService shutdown. Total queries: {}", m_totalQueries); m_logger->info("StorageService shutdown. Total queries: {}", m_totalQueries);
} }
} // namespace aissia } // namespace aissia

View File

@ -1,91 +1,91 @@
#pragma once #pragma once
#include "IService.hpp" #include "IService.hpp"
#include <grove/IIO.h> #include <grove/IIO.h>
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <memory> #include <memory>
#include <string> #include <string>
#include <queue> #include <queue>
#include <mutex> #include <mutex>
struct sqlite3; struct sqlite3;
struct sqlite3_stmt; struct sqlite3_stmt;
namespace aissia { namespace aissia {
/** /**
* @brief Storage Service - SQLite persistence * @brief Storage Service - SQLite persistence
* *
* Handles all database operations synchronously in main thread. * Handles all database operations synchronously in main thread.
* Uses prepared statements to prevent SQL injection. * Uses prepared statements to prevent SQL injection.
* *
* Subscribes to: * Subscribes to:
* - "storage:save_session" : { taskName, durationMinutes, hyperfocus } * - "storage:save_session" : { taskName, durationMinutes, hyperfocus }
* - "storage:save_app_usage" : { sessionId, appName, durationSeconds, productive } * - "storage:save_app_usage" : { sessionId, appName, durationSeconds, productive }
* - "storage:save_conversation" : { role, content, provider, model, tokens } * - "storage:save_conversation" : { role, content, provider, model, tokens }
* - "storage:update_metrics" : { focusMinutes, breaks, hyperfocusCount } * - "storage:update_metrics" : { focusMinutes, breaks, hyperfocusCount }
* - "storage:query" : { sql, params[] } * - "storage:query" : { sql, params[] }
* *
* Publishes: * Publishes:
* - "storage:ready" : Database initialized * - "storage:ready" : Database initialized
* - "storage:session_saved": { sessionId } * - "storage:session_saved": { sessionId }
* - "storage:error" : { message } * - "storage:error" : { message }
*/ */
class StorageService : public IService { class StorageService : public IService {
public: public:
StorageService(); StorageService();
~StorageService() override; ~StorageService() override;
bool initialize(grove::IIO* io) override; bool initialize(grove::IIO* io) override;
void process() override; void process() override;
void shutdown() override; void shutdown() override;
std::string getName() const override { return "StorageService"; } std::string getName() const override { return "StorageService"; }
bool isHealthy() const override { return m_isConnected; } bool isHealthy() const override { return m_isConnected; }
/// Open database with config /// Open database with config
bool openDatabase(const std::string& dbPath, bool openDatabase(const std::string& dbPath,
const std::string& journalMode = "WAL", const std::string& journalMode = "WAL",
int busyTimeoutMs = 5000); int busyTimeoutMs = 5000);
/// Get last inserted session ID /// Get last inserted session ID
int getLastSessionId() const { return m_lastSessionId; } int getLastSessionId() const { return m_lastSessionId; }
private: private:
// Database // Database
sqlite3* m_db = nullptr; sqlite3* m_db = nullptr;
std::string m_dbPath; std::string m_dbPath;
bool m_isConnected = false; bool m_isConnected = false;
int m_lastSessionId = 0; int m_lastSessionId = 0;
int m_totalQueries = 0; int m_totalQueries = 0;
// Prepared statements // Prepared statements
sqlite3_stmt* m_stmtSaveSession = nullptr; sqlite3_stmt* m_stmtSaveSession = nullptr;
sqlite3_stmt* m_stmtSaveAppUsage = nullptr; sqlite3_stmt* m_stmtSaveAppUsage = nullptr;
sqlite3_stmt* m_stmtSaveConversation = nullptr; sqlite3_stmt* m_stmtSaveConversation = nullptr;
sqlite3_stmt* m_stmtUpdateMetrics = nullptr; sqlite3_stmt* m_stmtUpdateMetrics = nullptr;
// Services // Services
grove::IIO* m_io = nullptr; grove::IIO* m_io = nullptr;
std::shared_ptr<spdlog::logger> m_logger; std::shared_ptr<spdlog::logger> m_logger;
// Database operations // Database operations
bool initializeSchema(); bool initializeSchema();
bool prepareStatements(); bool prepareStatements();
void finalizeStatements(); void finalizeStatements();
// Message handlers // Message handlers
void processMessages(); void processMessages();
void handleSaveSession(const grove::IDataNode& data); void handleSaveSession(const grove::IDataNode& data);
void handleSaveAppUsage(const grove::IDataNode& data); void handleSaveAppUsage(const grove::IDataNode& data);
void handleSaveConversation(const grove::IDataNode& data); void handleSaveConversation(const grove::IDataNode& data);
void handleUpdateMetrics(const grove::IDataNode& data); void handleUpdateMetrics(const grove::IDataNode& data);
// Helpers // Helpers
bool executeSQL(const std::string& sql); bool executeSQL(const std::string& sql);
void publishError(const std::string& message); void publishError(const std::string& message);
}; };
} // namespace aissia } // namespace aissia

View File

@ -1,294 +1,294 @@
// CRITICAL ORDER: Include system headers before local headers to avoid macro conflicts // CRITICAL ORDER: Include system headers before local headers to avoid macro conflicts
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <cstdlib> #include <cstdlib>
#include <memory> #include <memory>
#include <string> #include <string>
#include <queue> #include <queue>
#include <fstream> #include <fstream>
// Include VoiceService.hpp BEFORE spdlog to avoid logger macro conflicts // Include VoiceService.hpp BEFORE spdlog to avoid logger macro conflicts
#include "VoiceService.hpp" #include "VoiceService.hpp"
#include "STTService.hpp" #include "STTService.hpp"
// Include spdlog after VoiceService.hpp // Include spdlog after VoiceService.hpp
#include <spdlog/sinks/stdout_color_sinks.h> #include <spdlog/sinks/stdout_color_sinks.h>
namespace aissia { namespace aissia {
VoiceService::VoiceService() { VoiceService::VoiceService() {
m_logger = spdlog::get("VoiceService"); m_logger = spdlog::get("VoiceService");
if (!m_logger) { if (!m_logger) {
m_logger = spdlog::stdout_color_mt("VoiceService"); m_logger = spdlog::stdout_color_mt("VoiceService");
} }
} }
bool VoiceService::initialize(grove::IIO* io) { bool VoiceService::initialize(grove::IIO* io) {
m_io = io; m_io = io;
// Create TTS engine // Create TTS engine
m_ttsEngine = TTSEngineFactory::create(); m_ttsEngine = TTSEngineFactory::create();
if (m_ttsEngine && m_ttsEngine->isAvailable()) { if (m_ttsEngine && m_ttsEngine->isAvailable()) {
m_ttsEngine->setRate(m_ttsRate); m_ttsEngine->setRate(m_ttsRate);
m_ttsEngine->setVolume(m_ttsVolume); m_ttsEngine->setVolume(m_ttsVolume);
m_logger->info("TTS engine initialized"); m_logger->info("TTS engine initialized");
} else { } else {
m_logger->warn("TTS engine not available"); m_logger->warn("TTS engine not available");
} }
if (m_io) { if (m_io) {
grove::SubscriptionConfig config; grove::SubscriptionConfig config;
m_io->subscribe("voice:speak", config); m_io->subscribe("voice:speak", config);
m_io->subscribe("voice:stop", config); m_io->subscribe("voice:stop", config);
m_io->subscribe("voice:listen", config); m_io->subscribe("voice:listen", config);
} }
m_logger->info("VoiceService initialized"); m_logger->info("VoiceService initialized");
return true; return true;
} }
void VoiceService::configureTTS(bool enabled, int rate, int volume) { void VoiceService::configureTTS(bool enabled, int rate, int volume) {
m_ttsEnabled = enabled; m_ttsEnabled = enabled;
m_ttsRate = rate; m_ttsRate = rate;
m_ttsVolume = volume; m_ttsVolume = volume;
if (m_ttsEngine) { if (m_ttsEngine) {
m_ttsEngine->setRate(rate); m_ttsEngine->setRate(rate);
m_ttsEngine->setVolume(volume); m_ttsEngine->setVolume(volume);
} }
} }
void VoiceService::configureSTT(bool enabled, const std::string& language, void VoiceService::configureSTT(bool enabled, const std::string& language,
const std::string& apiKey) { const std::string& apiKey) {
m_sttEnabled = enabled; m_sttEnabled = enabled;
m_language = language; m_language = language;
if (!apiKey.empty()) { if (!apiKey.empty()) {
m_sttEngine = STTEngineFactory::create(apiKey); m_sttEngine = STTEngineFactory::create(apiKey);
if (m_sttEngine) { if (m_sttEngine) {
m_sttEngine->setLanguage(language); m_sttEngine->setLanguage(language);
m_logger->info("STT engine configured"); m_logger->info("STT engine configured");
} }
} }
} }
void VoiceService::process() { void VoiceService::process() {
processMessages(); processMessages();
processSpeakQueue(); processSpeakQueue();
} }
void VoiceService::processMessages() { void VoiceService::processMessages() {
if (!m_io) return; if (!m_io) return;
while (m_io->hasMessages() > 0) { while (m_io->hasMessages() > 0) {
auto msg = m_io->pullMessage(); auto msg = m_io->pullMessage();
if (msg.topic == "voice:speak" && msg.data) { if (msg.topic == "voice:speak" && msg.data) {
handleSpeakRequest(*msg.data); handleSpeakRequest(*msg.data);
} }
else if (msg.topic == "voice:stop") { else if (msg.topic == "voice:stop") {
if (m_ttsEngine) { if (m_ttsEngine) {
m_ttsEngine->stop(); m_ttsEngine->stop();
} }
// Clear queue // Clear queue
while (!m_speakQueue.empty()) m_speakQueue.pop(); while (!m_speakQueue.empty()) m_speakQueue.pop();
} }
else if (msg.topic == "voice:listen" && m_sttEnabled && m_sttEngine) { else if (msg.topic == "voice:listen" && m_sttEnabled && m_sttEngine) {
// STT would be handled here // STT would be handled here
// For now just log // For now just log
m_logger->debug("STT listen requested"); m_logger->debug("STT listen requested");
} }
} }
} }
void VoiceService::handleSpeakRequest(const grove::IDataNode& data) { void VoiceService::handleSpeakRequest(const grove::IDataNode& data) {
std::string text = data.getString("text", ""); std::string text = data.getString("text", "");
bool priority = data.getBool("priority", false); bool priority = data.getBool("priority", false);
if (text.empty()) return; if (text.empty()) return;
if (priority) { if (priority) {
// Clear queue and stop current speech // Clear queue and stop current speech
while (!m_speakQueue.empty()) m_speakQueue.pop(); while (!m_speakQueue.empty()) m_speakQueue.pop();
if (m_ttsEngine) m_ttsEngine->stop(); if (m_ttsEngine) m_ttsEngine->stop();
} }
m_speakQueue.push(text); m_speakQueue.push(text);
} }
void VoiceService::processSpeakQueue() { void VoiceService::processSpeakQueue() {
if (!m_ttsEnabled || !m_ttsEngine || m_speakQueue.empty()) return; if (!m_ttsEnabled || !m_ttsEngine || m_speakQueue.empty()) return;
// Only speak if not currently speaking // Only speak if not currently speaking
if (!m_ttsEngine->isSpeaking() && !m_speakQueue.empty()) { if (!m_ttsEngine->isSpeaking() && !m_speakQueue.empty()) {
std::string text = m_speakQueue.front(); std::string text = m_speakQueue.front();
m_speakQueue.pop(); m_speakQueue.pop();
speak(text); speak(text);
} }
} }
void VoiceService::speak(const std::string& text) { void VoiceService::speak(const std::string& text) {
if (!m_ttsEngine || !m_ttsEnabled) return; if (!m_ttsEngine || !m_ttsEnabled) return;
// Publish speaking started // Publish speaking started
if (m_io) { if (m_io) {
auto event = std::unique_ptr<grove::IDataNode>( auto event = std::unique_ptr<grove::IDataNode>(
new grove::JsonDataNode("event") new grove::JsonDataNode("event")
); );
event->setString("text", text.size() > 100 ? text.substr(0, 100) + "..." : text); event->setString("text", text.size() > 100 ? text.substr(0, 100) + "..." : text);
m_io->publish("voice:speaking_started", std::move(event)); m_io->publish("voice:speaking_started", std::move(event));
} }
m_ttsEngine->speak(text, true); m_ttsEngine->speak(text, true);
m_totalSpoken++; m_totalSpoken++;
m_logger->debug("Speaking"); m_logger->debug("Speaking");
} }
// Phase 7: New STT configuration with full config support // Phase 7: New STT configuration with full config support
void VoiceService::configureSTT(const nlohmann::json& sttConfig) { void VoiceService::configureSTT(const nlohmann::json& sttConfig) {
m_logger->info("[VoiceService] Configuring STT service (Phase 7)"); m_logger->info("[VoiceService] Configuring STT service (Phase 7)");
// Extract enabled flag // Extract enabled flag
bool enabled = false; bool enabled = false;
if (sttConfig.contains("active_mode")) { if (sttConfig.contains("active_mode")) {
const auto& activeMode = sttConfig["active_mode"]; const auto& activeMode = sttConfig["active_mode"];
enabled = activeMode.value("enabled", true); enabled = activeMode.value("enabled", true);
} }
m_sttEnabled = enabled; m_sttEnabled = enabled;
if (!enabled) { if (!enabled) {
m_logger->info("[VoiceService] STT disabled in config"); m_logger->info("[VoiceService] STT disabled in config");
return; return;
} }
// Create and start STT service // Create and start STT service
m_sttService = std::make_unique<STTService>(sttConfig); m_sttService = std::make_unique<STTService>(sttConfig);
if (!m_sttService->start()) { if (!m_sttService->start()) {
m_logger->error("[VoiceService] Failed to start STT service"); m_logger->error("[VoiceService] Failed to start STT service");
m_sttService.reset(); m_sttService.reset();
return; return;
} }
m_logger->info("[VoiceService] STT service started"); m_logger->info("[VoiceService] STT service started");
// Setup callbacks for transcription events // Setup callbacks for transcription events
// Note: For MVP Milestone 1, we don't start streaming yet // Note: For MVP Milestone 1, we don't start streaming yet
// This will be implemented in Milestone 2 (passive mode) // This will be implemented in Milestone 2 (passive mode)
} }
// STT event handlers (Phase 7) // STT event handlers (Phase 7)
void VoiceService::handleKeyword(const std::string& keyword) { void VoiceService::handleKeyword(const std::string& keyword) {
m_logger->info("[VoiceService] Keyword detected"); m_logger->info("[VoiceService] Keyword detected");
// Publish keyword detection event // Publish keyword detection event
if (m_io) { if (m_io) {
auto event = std::unique_ptr<grove::IDataNode>( auto event = std::unique_ptr<grove::IDataNode>(
new grove::JsonDataNode("event") new grove::JsonDataNode("event")
); );
event->setString("keyword", keyword); event->setString("keyword", keyword);
event->setInt("timestamp", static_cast<int>(std::time(nullptr))); event->setInt("timestamp", static_cast<int>(std::time(nullptr)));
m_io->publish("voice:keyword_detected", std::move(event)); m_io->publish("voice:keyword_detected", std::move(event));
} }
// Auto-switch to active mode (Phase 7.2) // Auto-switch to active mode (Phase 7.2)
if (m_sttService) { if (m_sttService) {
m_sttService->setMode(STTMode::ACTIVE); m_sttService->setMode(STTMode::ACTIVE);
} }
} }
void VoiceService::handleTranscription(const std::string& text, STTMode mode) { void VoiceService::handleTranscription(const std::string& text, STTMode mode) {
m_logger->info("[VoiceService] Transcription received"); m_logger->info("[VoiceService] Transcription received");
// Publish transcription event // Publish transcription event
if (m_io) { if (m_io) {
std::string modeStr = (mode == STTMode::PASSIVE ? "passive" : "active"); std::string modeStr = (mode == STTMode::PASSIVE ? "passive" : "active");
auto event = std::unique_ptr<grove::IDataNode>( auto event = std::unique_ptr<grove::IDataNode>(
new grove::JsonDataNode("event") new grove::JsonDataNode("event")
); );
event->setString("text", text); event->setString("text", text);
event->setString("mode", modeStr); event->setString("mode", modeStr);
event->setInt("timestamp", static_cast<int>(std::time(nullptr))); event->setInt("timestamp", static_cast<int>(std::time(nullptr)));
m_io->publish("voice:transcription", std::move(event)); m_io->publish("voice:transcription", std::move(event));
} }
} }
void VoiceService::shutdown() { void VoiceService::shutdown() {
if (m_ttsEngine) { if (m_ttsEngine) {
m_ttsEngine->stop(); m_ttsEngine->stop();
} }
if (m_sttService) { if (m_sttService) {
m_sttService->stop(); m_sttService->stop();
} }
m_logger->info("[VoiceService] Shutdown"); m_logger->info("[VoiceService] Shutdown");
} }
bool VoiceService::loadConfig(const std::string& configPath) { bool VoiceService::loadConfig(const std::string& configPath) {
try { try {
std::ifstream file(configPath); std::ifstream file(configPath);
if (!file.is_open()) { if (!file.is_open()) {
m_logger->warn("[VoiceService] Config file not found: {}", configPath); m_logger->warn("[VoiceService] Config file not found: {}", configPath);
return false; return false;
} }
nlohmann::json config; nlohmann::json config;
file >> config; file >> config;
// Load TTS config // Load TTS config
if (config.contains("tts")) { if (config.contains("tts")) {
const auto& ttsConfig = config["tts"]; const auto& ttsConfig = config["tts"];
m_ttsEnabled = ttsConfig.value("enabled", true); m_ttsEnabled = ttsConfig.value("enabled", true);
m_ttsRate = ttsConfig.value("rate", 0); m_ttsRate = ttsConfig.value("rate", 0);
m_ttsVolume = ttsConfig.value("volume", 80); m_ttsVolume = ttsConfig.value("volume", 80);
} }
// Load STT config (Phase 7 format) // Load STT config (Phase 7 format)
if (config.contains("stt")) { if (config.contains("stt")) {
configureSTT(config["stt"]); configureSTT(config["stt"]);
} }
m_logger->info("[VoiceService] Config loaded from {}", configPath); m_logger->info("[VoiceService] Config loaded from {}", configPath);
return true; return true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
m_logger->error("[VoiceService] Failed to load config: {}", e.what()); m_logger->error("[VoiceService] Failed to load config: {}", e.what());
return false; return false;
} }
} }
std::string VoiceService::transcribeFileSync( std::string VoiceService::transcribeFileSync(
const std::string& filePath, const std::string& filePath,
const std::string& language const std::string& language
) { ) {
m_logger->info("[VoiceService] transcribeFileSync: {}", filePath); m_logger->info("[VoiceService] transcribeFileSync: {}", filePath);
if (!m_sttService) { if (!m_sttService) {
throw std::runtime_error("STT service not initialized"); throw std::runtime_error("STT service not initialized");
} }
// Use STT service to transcribe file synchronously // Use STT service to transcribe file synchronously
// Note: This requires STT service to support file transcription // Note: This requires STT service to support file transcription
// For MVP, we'll throw not implemented // For MVP, we'll throw not implemented
throw std::runtime_error("transcribeFileSync not yet implemented - STT service needs file transcription support"); throw std::runtime_error("transcribeFileSync not yet implemented - STT service needs file transcription support");
} }
bool VoiceService::textToSpeechSync( bool VoiceService::textToSpeechSync(
const std::string& text, const std::string& text,
const std::string& outputFile, const std::string& outputFile,
const std::string& voice const std::string& voice
) { ) {
m_logger->info("[VoiceService] textToSpeechSync: {} -> {}", text.substr(0, 50), outputFile); m_logger->info("[VoiceService] textToSpeechSync: {} -> {}", text.substr(0, 50), outputFile);
if (!m_ttsEngine) { if (!m_ttsEngine) {
throw std::runtime_error("TTS engine not initialized"); throw std::runtime_error("TTS engine not initialized");
} }
// For MVP, we don't support saving to file yet // For MVP, we don't support saving to file yet
// The TTS engine currently only speaks directly // The TTS engine currently only speaks directly
throw std::runtime_error("textToSpeechSync file output not yet implemented - TTS engine needs file output support"); throw std::runtime_error("textToSpeechSync file output not yet implemented - TTS engine needs file output support");
} }
} // namespace aissia } // namespace aissia

View File

@ -1,117 +1,117 @@
#pragma once #pragma once
// Include nlohmann/json BEFORE grove headers to avoid macro conflicts // Include nlohmann/json BEFORE grove headers to avoid macro conflicts
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "IService.hpp" #include "IService.hpp"
#include "ISTTService.hpp" #include "ISTTService.hpp"
#include "../shared/audio/ITTSEngine.hpp" #include "../shared/audio/ITTSEngine.hpp"
#include "../shared/audio/ISTTEngine.hpp" #include "../shared/audio/ISTTEngine.hpp"
#include <grove/IIO.h> #include <grove/IIO.h>
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <memory> #include <memory>
#include <string> #include <string>
#include <queue> #include <queue>
namespace aissia { namespace aissia {
/** /**
* @brief Voice Service - TTS and STT engines * @brief Voice Service - TTS and STT engines
* *
* Handles platform-specific audio engines (SAPI on Windows, espeak on Linux). * Handles platform-specific audio engines (SAPI on Windows, espeak on Linux).
* Manages speak queue and processes TTS/STT requests. * Manages speak queue and processes TTS/STT requests.
* *
* Subscribes to: * Subscribes to:
* - "voice:speak" : { text, priority? } * - "voice:speak" : { text, priority? }
* - "voice:stop" : Stop current speech * - "voice:stop" : Stop current speech
* - "voice:listen" : Start STT recording * - "voice:listen" : Start STT recording
* *
* Publishes: * Publishes:
* - "voice:speaking_started" : { text } * - "voice:speaking_started" : { text }
* - "voice:speaking_ended" : {} * - "voice:speaking_ended" : {}
* - "voice:transcription" : { text, confidence } * - "voice:transcription" : { text, confidence }
*/ */
class VoiceService : public IService { class VoiceService : public IService {
public: public:
VoiceService(); VoiceService();
~VoiceService() override = default; ~VoiceService() override = default;
bool initialize(grove::IIO* io) override; bool initialize(grove::IIO* io) override;
void process() override; void process() override;
void shutdown() override; void shutdown() override;
std::string getName() const override { return "VoiceService"; } std::string getName() const override { return "VoiceService"; }
bool isHealthy() const override { return m_ttsEngine != nullptr; } bool isHealthy() const override { return m_ttsEngine != nullptr; }
/// Configure TTS settings /// Configure TTS settings
void configureTTS(bool enabled = true, int rate = 0, int volume = 80); void configureTTS(bool enabled = true, int rate = 0, int volume = 80);
/// Configure STT settings (legacy API) /// Configure STT settings (legacy API)
void configureSTT(bool enabled = true, const std::string& language = "fr", void configureSTT(bool enabled = true, const std::string& language = "fr",
const std::string& apiKey = ""); const std::string& apiKey = "");
/// Configure STT with full config (Phase 7) /// Configure STT with full config (Phase 7)
void configureSTT(const nlohmann::json& sttConfig); void configureSTT(const nlohmann::json& sttConfig);
/// Load configuration from JSON file /// Load configuration from JSON file
bool loadConfig(const std::string& configPath); bool loadConfig(const std::string& configPath);
/** /**
* @brief Transcribe audio file synchronously (for MCP Server mode) * @brief Transcribe audio file synchronously (for MCP Server mode)
* *
* @param filePath Path to audio file * @param filePath Path to audio file
* @param language Language code (e.g., "fr", "en") * @param language Language code (e.g., "fr", "en")
* @return Transcribed text * @return Transcribed text
*/ */
std::string transcribeFileSync( std::string transcribeFileSync(
const std::string& filePath, const std::string& filePath,
const std::string& language = "fr" const std::string& language = "fr"
); );
/** /**
* @brief Convert text to speech synchronously (for MCP Server mode) * @brief Convert text to speech synchronously (for MCP Server mode)
* *
* @param text Text to synthesize * @param text Text to synthesize
* @param outputFile Output audio file path * @param outputFile Output audio file path
* @param voice Voice identifier (e.g., "fr-fr") * @param voice Voice identifier (e.g., "fr-fr")
* @return true if successful * @return true if successful
*/ */
bool textToSpeechSync( bool textToSpeechSync(
const std::string& text, const std::string& text,
const std::string& outputFile, const std::string& outputFile,
const std::string& voice = "fr-fr" const std::string& voice = "fr-fr"
); );
private: private:
// Configuration // Configuration
bool m_ttsEnabled = true; bool m_ttsEnabled = true;
bool m_sttEnabled = true; bool m_sttEnabled = true;
int m_ttsRate = 0; int m_ttsRate = 0;
int m_ttsVolume = 80; int m_ttsVolume = 80;
std::string m_language = "fr"; std::string m_language = "fr";
// State // State
std::unique_ptr<ITTSEngine> m_ttsEngine; std::unique_ptr<ITTSEngine> m_ttsEngine;
std::unique_ptr<ISTTEngine> m_sttEngine; // Legacy direct engine (deprecated) std::unique_ptr<ISTTEngine> m_sttEngine; // Legacy direct engine (deprecated)
std::unique_ptr<ISTTService> m_sttService; // Phase 7: New STT service layer std::unique_ptr<ISTTService> m_sttService; // Phase 7: New STT service layer
std::queue<std::string> m_speakQueue; std::queue<std::string> m_speakQueue;
int m_totalSpoken = 0; int m_totalSpoken = 0;
// Services // Services
grove::IIO* m_io = nullptr; grove::IIO* m_io = nullptr;
std::shared_ptr<spdlog::logger> m_logger; std::shared_ptr<spdlog::logger> m_logger;
// Helpers // Helpers
void processMessages(); void processMessages();
void processSpeakQueue(); void processSpeakQueue();
void speak(const std::string& text); void speak(const std::string& text);
void handleSpeakRequest(const grove::IDataNode& data); void handleSpeakRequest(const grove::IDataNode& data);
// STT handlers (Phase 7) // STT handlers (Phase 7)
void handleTranscription(const std::string& text, STTMode mode); void handleTranscription(const std::string& text, STTMode mode);
void handleKeyword(const std::string& keyword); void handleKeyword(const std::string& keyword);
}; };
} // namespace aissia } // namespace aissia

View File

@ -1,310 +1,310 @@
#include "MCPServerTools.hpp" #include "MCPServerTools.hpp"
#include "../../services/LLMService.hpp" #include "../../services/LLMService.hpp"
#include "../../services/StorageService.hpp" #include "../../services/StorageService.hpp"
#include "../../services/VoiceService.hpp" #include "../../services/VoiceService.hpp"
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
namespace aissia::tools { namespace aissia::tools {
MCPServerTools::MCPServerTools( MCPServerTools::MCPServerTools(
LLMService* llm, LLMService* llm,
StorageService* storage, StorageService* storage,
VoiceService* voice VoiceService* voice
) : m_llmService(llm), ) : m_llmService(llm),
m_storageService(storage), m_storageService(storage),
m_voiceService(voice) m_voiceService(voice)
{ {
} }
std::vector<ToolDefinition> MCPServerTools::getToolDefinitions() { std::vector<ToolDefinition> MCPServerTools::getToolDefinitions() {
std::vector<ToolDefinition> tools; std::vector<ToolDefinition> tools;
// Tool 1: chat_with_aissia (PRIORITÉ) // Tool 1: chat_with_aissia (PRIORITÉ)
if (m_llmService) { if (m_llmService) {
tools.push_back({ tools.push_back({
"chat_with_aissia", "chat_with_aissia",
"Dialogue with AISSIA assistant (Claude Sonnet 4). Send a message and get an intelligent response with access to AISSIA's knowledge and capabilities.", "Dialogue with AISSIA assistant (Claude Sonnet 4). Send a message and get an intelligent response with access to AISSIA's knowledge and capabilities.",
{ {
{"type", "object"}, {"type", "object"},
{"properties", { {"properties", {
{"message", { {"message", {
{"type", "string"}, {"type", "string"},
{"description", "Message to send to AISSIA"} {"description", "Message to send to AISSIA"}
}}, }},
{"conversation_id", { {"conversation_id", {
{"type", "string"}, {"type", "string"},
{"description", "Conversation ID for continuity (optional)"} {"description", "Conversation ID for continuity (optional)"}
}}, }},
{"system_prompt", { {"system_prompt", {
{"type", "string"}, {"type", "string"},
{"description", "Custom system prompt (optional)"} {"description", "Custom system prompt (optional)"}
}} }}
}}, }},
{"required", json::array({"message"})} {"required", json::array({"message"})}
}, },
[this](const json& input) { return handleChatWithAissia(input); } [this](const json& input) { return handleChatWithAissia(input); }
}); });
} }
// Tool 2: transcribe_audio // Tool 2: transcribe_audio
if (m_voiceService) { if (m_voiceService) {
tools.push_back({ tools.push_back({
"transcribe_audio", "transcribe_audio",
"Transcribe audio file to text using Speech-to-Text (Whisper.cpp, OpenAI Whisper API, or Google Speech). Supports WAV, MP3, and other common audio formats.", "Transcribe audio file to text using Speech-to-Text (Whisper.cpp, OpenAI Whisper API, or Google Speech). Supports WAV, MP3, and other common audio formats.",
{ {
{"type", "object"}, {"type", "object"},
{"properties", { {"properties", {
{"file_path", { {"file_path", {
{"type", "string"}, {"type", "string"},
{"description", "Path to audio file"} {"description", "Path to audio file"}
}}, }},
{"language", { {"language", {
{"type", "string"}, {"type", "string"},
{"description", "Language code (e.g., 'fr', 'en'). Default: 'fr'"} {"description", "Language code (e.g., 'fr', 'en'). Default: 'fr'"}
}} }}
}}, }},
{"required", json::array({"file_path"})} {"required", json::array({"file_path"})}
}, },
[this](const json& input) { return handleTranscribeAudio(input); } [this](const json& input) { return handleTranscribeAudio(input); }
}); });
// Tool 3: text_to_speech // Tool 3: text_to_speech
tools.push_back({ tools.push_back({
"text_to_speech", "text_to_speech",
"Convert text to speech audio file using Text-to-Speech synthesis. Generates audio in WAV format.", "Convert text to speech audio file using Text-to-Speech synthesis. Generates audio in WAV format.",
{ {
{"type", "object"}, {"type", "object"},
{"properties", { {"properties", {
{"text", { {"text", {
{"type", "string"}, {"type", "string"},
{"description", "Text to synthesize"} {"description", "Text to synthesize"}
}}, }},
{"output_file", { {"output_file", {
{"type", "string"}, {"type", "string"},
{"description", "Output audio file path (WAV)"} {"description", "Output audio file path (WAV)"}
}}, }},
{"voice", { {"voice", {
{"type", "string"}, {"type", "string"},
{"description", "Voice identifier (e.g., 'fr-fr', 'en-us'). Default: 'fr-fr'"} {"description", "Voice identifier (e.g., 'fr-fr', 'en-us'). Default: 'fr-fr'"}
}} }}
}}, }},
{"required", json::array({"text", "output_file"})} {"required", json::array({"text", "output_file"})}
}, },
[this](const json& input) { return handleTextToSpeech(input); } [this](const json& input) { return handleTextToSpeech(input); }
}); });
} }
// Tool 4: save_memory // Tool 4: save_memory
if (m_storageService) { if (m_storageService) {
tools.push_back({ tools.push_back({
"save_memory", "save_memory",
"Save a note or memory to AISSIA's persistent storage. Memories can be tagged and searched later.", "Save a note or memory to AISSIA's persistent storage. Memories can be tagged and searched later.",
{ {
{"type", "object"}, {"type", "object"},
{"properties", { {"properties", {
{"title", { {"title", {
{"type", "string"}, {"type", "string"},
{"description", "Memory title"} {"description", "Memory title"}
}}, }},
{"content", { {"content", {
{"type", "string"}, {"type", "string"},
{"description", "Memory content"} {"description", "Memory content"}
}}, }},
{"tags", { {"tags", {
{"type", "array"}, {"type", "array"},
{"items", {{"type", "string"}}}, {"items", {{"type", "string"}}},
{"description", "Tags for categorization (optional)"} {"description", "Tags for categorization (optional)"}
}} }}
}}, }},
{"required", json::array({"title", "content"})} {"required", json::array({"title", "content"})}
}, },
[this](const json& input) { return handleSaveMemory(input); } [this](const json& input) { return handleSaveMemory(input); }
}); });
// Tool 5: search_memories // Tool 5: search_memories
tools.push_back({ tools.push_back({
"search_memories", "search_memories",
"Search through saved memories and notes in AISSIA's storage. Returns matching memories with relevance scores.", "Search through saved memories and notes in AISSIA's storage. Returns matching memories with relevance scores.",
{ {
{"type", "object"}, {"type", "object"},
{"properties", { {"properties", {
{"query", { {"query", {
{"type", "string"}, {"type", "string"},
{"description", "Search query"} {"description", "Search query"}
}}, }},
{"limit", { {"limit", {
{"type", "integer"}, {"type", "integer"},
{"description", "Maximum results to return. Default: 10"} {"description", "Maximum results to return. Default: 10"}
}} }}
}}, }},
{"required", json::array({"query"})} {"required", json::array({"query"})}
}, },
[this](const json& input) { return handleSearchMemories(input); } [this](const json& input) { return handleSearchMemories(input); }
}); });
} }
return tools; return tools;
} }
json MCPServerTools::execute(const std::string& toolName, const json& input) { json MCPServerTools::execute(const std::string& toolName, const json& input) {
if (toolName == "chat_with_aissia") { if (toolName == "chat_with_aissia") {
return handleChatWithAissia(input); return handleChatWithAissia(input);
} else if (toolName == "transcribe_audio") { } else if (toolName == "transcribe_audio") {
return handleTranscribeAudio(input); return handleTranscribeAudio(input);
} else if (toolName == "text_to_speech") { } else if (toolName == "text_to_speech") {
return handleTextToSpeech(input); return handleTextToSpeech(input);
} else if (toolName == "save_memory") { } else if (toolName == "save_memory") {
return handleSaveMemory(input); return handleSaveMemory(input);
} else if (toolName == "search_memories") { } else if (toolName == "search_memories") {
return handleSearchMemories(input); return handleSearchMemories(input);
} }
return { return {
{"error", "Unknown tool: " + toolName} {"error", "Unknown tool: " + toolName}
}; };
} }
// ============================================================================ // ============================================================================
// Tool Handlers // Tool Handlers
// ============================================================================ // ============================================================================
json MCPServerTools::handleChatWithAissia(const json& input) { json MCPServerTools::handleChatWithAissia(const json& input) {
if (!m_llmService) { if (!m_llmService) {
return {{"error", "LLMService not available"}}; return {{"error", "LLMService not available"}};
} }
try { try {
std::string message = input["message"]; std::string message = input["message"];
std::string conversationId = input.value("conversation_id", ""); std::string conversationId = input.value("conversation_id", "");
std::string systemPrompt = input.value("system_prompt", ""); std::string systemPrompt = input.value("system_prompt", "");
spdlog::info("[chat_with_aissia] Message: {}", message.substr(0, 100)); spdlog::info("[chat_with_aissia] Message: {}", message.substr(0, 100));
// Call synchronous LLM method // Call synchronous LLM method
auto response = m_llmService->sendMessageSync(message, conversationId, systemPrompt); auto response = m_llmService->sendMessageSync(message, conversationId, systemPrompt);
return { return {
{"response", response.text}, {"response", response.text},
{"conversation_id", conversationId}, {"conversation_id", conversationId},
{"tokens", response.tokens}, {"tokens", response.tokens},
{"iterations", response.iterations} {"iterations", response.iterations}
}; };
} catch (const std::exception& e) { } catch (const std::exception& e) {
spdlog::error("[chat_with_aissia] Error: {}", e.what()); spdlog::error("[chat_with_aissia] Error: {}", e.what());
return {{"error", e.what()}}; return {{"error", e.what()}};
} }
} }
json MCPServerTools::handleTranscribeAudio(const json& input) { json MCPServerTools::handleTranscribeAudio(const json& input) {
if (!m_voiceService) { if (!m_voiceService) {
return {{"error", "VoiceService not available"}}; return {{"error", "VoiceService not available"}};
} }
try { try {
std::string filePath = input["file_path"]; std::string filePath = input["file_path"];
std::string language = input.value("language", "fr"); std::string language = input.value("language", "fr");
spdlog::info("[transcribe_audio] File: {}, Language: {}", filePath, language); spdlog::info("[transcribe_audio] File: {}, Language: {}", filePath, language);
// Call synchronous STT method // Call synchronous STT method
std::string text = m_voiceService->transcribeFileSync(filePath, language); std::string text = m_voiceService->transcribeFileSync(filePath, language);
return { return {
{"text", text}, {"text", text},
{"file", filePath}, {"file", filePath},
{"language", language} {"language", language}
}; };
} catch (const std::exception& e) { } catch (const std::exception& e) {
spdlog::error("[transcribe_audio] Error: {}", e.what()); spdlog::error("[transcribe_audio] Error: {}", e.what());
return {{"error", e.what()}}; return {{"error", e.what()}};
} }
} }
json MCPServerTools::handleTextToSpeech(const json& input) { json MCPServerTools::handleTextToSpeech(const json& input) {
if (!m_voiceService) { if (!m_voiceService) {
return {{"error", "VoiceService not available"}}; return {{"error", "VoiceService not available"}};
} }
try { try {
std::string text = input["text"]; std::string text = input["text"];
std::string outputFile = input["output_file"]; std::string outputFile = input["output_file"];
std::string voice = input.value("voice", "fr-fr"); std::string voice = input.value("voice", "fr-fr");
spdlog::info("[text_to_speech] Text: {}, Output: {}", text.substr(0, 50), outputFile); spdlog::info("[text_to_speech] Text: {}, Output: {}", text.substr(0, 50), outputFile);
// Call synchronous TTS method // Call synchronous TTS method
bool success = m_voiceService->textToSpeechSync(text, outputFile, voice); bool success = m_voiceService->textToSpeechSync(text, outputFile, voice);
if (success) { if (success) {
return { return {
{"success", true}, {"success", true},
{"file", outputFile}, {"file", outputFile},
{"voice", voice} {"voice", voice}
}; };
} else { } else {
return {{"error", "TTS generation failed"}}; return {{"error", "TTS generation failed"}};
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
spdlog::error("[text_to_speech] Error: {}", e.what()); spdlog::error("[text_to_speech] Error: {}", e.what());
return {{"error", e.what()}}; return {{"error", e.what()}};
} }
} }
json MCPServerTools::handleSaveMemory(const json& input) { json MCPServerTools::handleSaveMemory(const json& input) {
if (!m_storageService) { if (!m_storageService) {
return {{"error", "StorageService not available"}}; return {{"error", "StorageService not available"}};
} }
try { try {
std::string title = input["title"]; std::string title = input["title"];
std::string content = input["content"]; std::string content = input["content"];
std::vector<std::string> tags; std::vector<std::string> tags;
if (input.contains("tags") && input["tags"].is_array()) { if (input.contains("tags") && input["tags"].is_array()) {
for (const auto& tag : input["tags"]) { for (const auto& tag : input["tags"]) {
tags.push_back(tag.get<std::string>()); tags.push_back(tag.get<std::string>());
} }
} }
spdlog::info("[save_memory] Title: {}", title); spdlog::info("[save_memory] Title: {}", title);
// TODO: Implement saveMemorySync in StorageService // TODO: Implement saveMemorySync in StorageService
// For now, return not implemented // For now, return not implemented
return json({ return json({
{"error", "save_memory not yet implemented"}, {"error", "save_memory not yet implemented"},
{"note", "StorageService sync methods need to be added"}, {"note", "StorageService sync methods need to be added"},
{"title", title} {"title", title}
}); });
} catch (const std::exception& e) { } catch (const std::exception& e) {
spdlog::error("[save_memory] Error: {}", e.what()); spdlog::error("[save_memory] Error: {}", e.what());
return {{"error", e.what()}}; return {{"error", e.what()}};
} }
} }
json MCPServerTools::handleSearchMemories(const json& input) { json MCPServerTools::handleSearchMemories(const json& input) {
if (!m_storageService) { if (!m_storageService) {
return {{"error", "StorageService not available"}}; return {{"error", "StorageService not available"}};
} }
try { try {
std::string query = input["query"]; std::string query = input["query"];
int limit = input.value("limit", 10); int limit = input.value("limit", 10);
spdlog::info("[search_memories] Query: {}, Limit: {}", query, limit); spdlog::info("[search_memories] Query: {}, Limit: {}", query, limit);
// TODO: Implement searchMemoriesSync in StorageService // TODO: Implement searchMemoriesSync in StorageService
// For now, return not implemented // For now, return not implemented
return json({ return json({
{"error", "search_memories not yet implemented"}, {"error", "search_memories not yet implemented"},
{"note", "StorageService sync methods need to be added"}, {"note", "StorageService sync methods need to be added"},
{"query", query}, {"query", query},
{"limit", limit} {"limit", limit}
}); });
} catch (const std::exception& e) { } catch (const std::exception& e) {
spdlog::error("[search_memories] Error: {}", e.what()); spdlog::error("[search_memories] Error: {}", e.what());
return {{"error", e.what()}}; return {{"error", e.what()}};
} }
} }
} // namespace aissia::tools } // namespace aissia::tools

View File

@ -1,76 +1,76 @@
#pragma once #pragma once
#include "../llm/ToolRegistry.hpp" #include "../llm/ToolRegistry.hpp"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <memory> #include <memory>
#include <vector> #include <vector>
// Forward declarations // Forward declarations
namespace aissia { namespace aissia {
class LLMService; class LLMService;
class StorageService; class StorageService;
class VoiceService; class VoiceService;
} }
namespace aissia::tools { namespace aissia::tools {
using json = nlohmann::json; using json = nlohmann::json;
/** /**
* @brief MCP Server Tools - Bridge between MCP Server and AISSIA services * @brief MCP Server Tools - Bridge between MCP Server and AISSIA services
* *
* Provides tool definitions for AISSIA modules exposed via MCP Server: * Provides tool definitions for AISSIA modules exposed via MCP Server:
* - chat_with_aissia: Dialogue with AISSIA (Claude Sonnet 4) * - chat_with_aissia: Dialogue with AISSIA (Claude Sonnet 4)
* - transcribe_audio: Speech-to-text (Whisper.cpp/OpenAI/Google) * - transcribe_audio: Speech-to-text (Whisper.cpp/OpenAI/Google)
* - text_to_speech: Text-to-speech synthesis * - text_to_speech: Text-to-speech synthesis
* - save_memory: Save note/memory to storage * - save_memory: Save note/memory to storage
* - search_memories: Search stored memories * - search_memories: Search stored memories
* *
* Note: These tools run in synchronous mode (no IIO pub/sub, no main loop) * Note: These tools run in synchronous mode (no IIO pub/sub, no main loop)
*/ */
class MCPServerTools { class MCPServerTools {
public: public:
/** /**
* @brief Construct MCP server tools with service dependencies * @brief Construct MCP server tools with service dependencies
* *
* @param llm LLMService for chat_with_aissia (can be nullptr) * @param llm LLMService for chat_with_aissia (can be nullptr)
* @param storage StorageService for save/search memories (can be nullptr) * @param storage StorageService for save/search memories (can be nullptr)
* @param voice VoiceService for TTS/STT (can be nullptr) * @param voice VoiceService for TTS/STT (can be nullptr)
*/ */
MCPServerTools( MCPServerTools(
LLMService* llm, LLMService* llm,
StorageService* storage, StorageService* storage,
VoiceService* voice VoiceService* voice
); );
/** /**
* @brief Get all tool definitions for registration * @brief Get all tool definitions for registration
* *
* @return Vector of ToolDefinition structs * @return Vector of ToolDefinition structs
*/ */
std::vector<ToolDefinition> getToolDefinitions(); std::vector<ToolDefinition> getToolDefinitions();
/** /**
* @brief Execute a tool by name * @brief Execute a tool by name
* *
* @param toolName Tool to execute * @param toolName Tool to execute
* @param input Tool arguments (JSON) * @param input Tool arguments (JSON)
* @return Tool result (JSON) * @return Tool result (JSON)
*/ */
json execute(const std::string& toolName, const json& input); json execute(const std::string& toolName, const json& input);
private: private:
// Tool handlers // Tool handlers
json handleChatWithAissia(const json& input); json handleChatWithAissia(const json& input);
json handleTranscribeAudio(const json& input); json handleTranscribeAudio(const json& input);
json handleTextToSpeech(const json& input); json handleTextToSpeech(const json& input);
json handleSaveMemory(const json& input); json handleSaveMemory(const json& input);
json handleSearchMemories(const json& input); json handleSearchMemories(const json& input);
// Service references (nullable) // Service references (nullable)
LLMService* m_llmService; LLMService* m_llmService;
StorageService* m_storageService; StorageService* m_storageService;
VoiceService* m_voiceService; VoiceService* m_voiceService;
}; };
} // namespace aissia::tools } // namespace aissia::tools

View File

@ -1,16 +1,16 @@
{ {
"environment": { "environment": {
"platform": "linux", "platform": "linux",
"testDirectory": "tests/integration" "testDirectory": "tests/integration"
}, },
"summary": { "summary": {
"failed": 0, "failed": 0,
"passed": 0, "passed": 0,
"skipped": 0, "skipped": 0,
"successRate": 0.0, "successRate": 0.0,
"total": 0, "total": 0,
"totalDurationMs": 0 "totalDurationMs": 0
}, },
"tests": [], "tests": [],
"timestamp": "2025-11-29T09:01:38Z" "timestamp": "2025-11-29T09:01:38Z"
} }

View File

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
set -a set -a
source .env source .env
set +a set +a
echo "Quelle heure est-il ?" | timeout 30 ./build/aissia --interactive echo "Quelle heure est-il ?" | timeout 30 ./build/aissia --interactive

BIN
test_output.txt Normal file

Binary file not shown.

View File

@ -1,237 +1,237 @@
/** /**
* @file test_stt_live.cpp * @file test_stt_live.cpp
* @brief Live STT testing tool - Test all 4 engines * @brief Live STT testing tool - Test all 4 engines
*/ */
#include "src/shared/audio/ISTTEngine.hpp" #include "src/shared/audio/ISTTEngine.hpp"
#include <spdlog/spdlog.h> #include <spdlog/spdlog.h>
#include <iostream> #include <iostream>
#include <fstream> #include <fstream>
#include <vector> #include <vector>
#include <cstdlib> #include <cstdlib>
using namespace aissia; using namespace aissia;
// Helper: Load .env file // Helper: Load .env file
void loadEnv(const std::string& path = ".env") { void loadEnv(const std::string& path = ".env") {
std::ifstream file(path); std::ifstream file(path);
if (!file.is_open()) { if (!file.is_open()) {
spdlog::warn("No .env file found at: {}", path); spdlog::warn("No .env file found at: {}", path);
return; return;
} }
std::string line; std::string line;
while (std::getline(file, line)) { while (std::getline(file, line)) {
if (line.empty() || line[0] == '#') continue; if (line.empty() || line[0] == '#') continue;
auto pos = line.find('='); auto pos = line.find('=');
if (pos != std::string::npos) { if (pos != std::string::npos) {
std::string key = line.substr(0, pos); std::string key = line.substr(0, pos);
std::string value = line.substr(pos + 1); std::string value = line.substr(pos + 1);
// Remove quotes // Remove quotes
if (!value.empty() && value.front() == '"' && value.back() == '"') { if (!value.empty() && value.front() == '"' && value.back() == '"') {
value = value.substr(1, value.length() - 2); value = value.substr(1, value.length() - 2);
} }
#ifdef _WIN32 #ifdef _WIN32
_putenv_s(key.c_str(), value.c_str()); _putenv_s(key.c_str(), value.c_str());
#else #else
setenv(key.c_str(), value.c_str(), 1); setenv(key.c_str(), value.c_str(), 1);
#endif #endif
} }
} }
spdlog::info("Loaded environment from {}", path); spdlog::info("Loaded environment from {}", path);
} }
// Helper: Get API key from env // Helper: Get API key from env
std::string getEnvVar(const std::string& name) { std::string getEnvVar(const std::string& name) {
const char* val = std::getenv(name.c_str()); const char* val = std::getenv(name.c_str());
return val ? std::string(val) : ""; return val ? std::string(val) : "";
} }
// Helper: Load audio file as WAV (simplified - assumes 16-bit PCM) // Helper: Load audio file as WAV (simplified - assumes 16-bit PCM)
std::vector<float> loadWavFile(const std::string& path) { std::vector<float> loadWavFile(const std::string& path) {
std::ifstream file(path, std::ios::binary); std::ifstream file(path, std::ios::binary);
if (!file.is_open()) { if (!file.is_open()) {
spdlog::error("Failed to open audio file: {}", path); spdlog::error("Failed to open audio file: {}", path);
return {}; return {};
} }
// Skip WAV header (44 bytes) // Skip WAV header (44 bytes)
file.seekg(44); file.seekg(44);
// Read 16-bit PCM samples // Read 16-bit PCM samples
std::vector<int16_t> samples; std::vector<int16_t> samples;
int16_t sample; int16_t sample;
while (file.read(reinterpret_cast<char*>(&sample), sizeof(sample))) { while (file.read(reinterpret_cast<char*>(&sample), sizeof(sample))) {
samples.push_back(sample); samples.push_back(sample);
} }
// Convert to float [-1.0, 1.0] // Convert to float [-1.0, 1.0]
std::vector<float> audioData; std::vector<float> audioData;
audioData.reserve(samples.size()); audioData.reserve(samples.size());
for (int16_t s : samples) { for (int16_t s : samples) {
audioData.push_back(static_cast<float>(s) / 32768.0f); audioData.push_back(static_cast<float>(s) / 32768.0f);
} }
spdlog::info("Loaded {} samples from {}", audioData.size(), path); spdlog::info("Loaded {} samples from {}", audioData.size(), path);
return audioData; return audioData;
} }
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
spdlog::set_level(spdlog::level::info); spdlog::set_level(spdlog::level::info);
spdlog::info("=== AISSIA STT Live Test ==="); spdlog::info("=== AISSIA STT Live Test ===");
// Load environment variables // Load environment variables
loadEnv(); loadEnv();
// Check command line // Check command line
if (argc < 2) { if (argc < 2) {
std::cout << "Usage: " << argv[0] << " <audio.wav>\n"; std::cout << "Usage: " << argv[0] << " <audio.wav>\n";
std::cout << "\nAvailable engines:\n"; std::cout << "\nAvailable engines:\n";
std::cout << " 1. Whisper.cpp (local, requires models/ggml-base.bin)\n"; std::cout << " 1. Whisper.cpp (local, requires models/ggml-base.bin)\n";
std::cout << " 2. Whisper API (requires OPENAI_API_KEY)\n"; std::cout << " 2. Whisper API (requires OPENAI_API_KEY)\n";
std::cout << " 3. Google Speech (requires GOOGLE_API_KEY)\n"; std::cout << " 3. Google Speech (requires GOOGLE_API_KEY)\n";
std::cout << " 4. Azure STT (requires AZURE_SPEECH_KEY + AZURE_SPEECH_REGION)\n"; std::cout << " 4. Azure STT (requires AZURE_SPEECH_KEY + AZURE_SPEECH_REGION)\n";
std::cout << " 5. Deepgram (requires DEEPGRAM_API_KEY)\n"; std::cout << " 5. Deepgram (requires DEEPGRAM_API_KEY)\n";
return 1; return 1;
} }
std::string audioFile = argv[1]; std::string audioFile = argv[1];
// Load audio // Load audio
std::vector<float> audioData = loadWavFile(audioFile); std::vector<float> audioData = loadWavFile(audioFile);
if (audioData.empty()) { if (audioData.empty()) {
spdlog::error("Failed to load audio data"); spdlog::error("Failed to load audio data");
return 1; return 1;
} }
// Test each engine // Test each engine
std::cout << "\n========================================\n"; std::cout << "\n========================================\n";
std::cout << "Testing STT Engines\n"; std::cout << "Testing STT Engines\n";
std::cout << "========================================\n\n"; std::cout << "========================================\n\n";
// 1. Whisper.cpp (local) // 1. Whisper.cpp (local)
{ {
std::cout << "[1/5] Whisper.cpp (local)\n"; std::cout << "[1/5] Whisper.cpp (local)\n";
std::cout << "----------------------------\n"; std::cout << "----------------------------\n";
try { try {
auto engine = STTEngineFactory::create("whisper_cpp", "models/ggml-base.bin"); auto engine = STTEngineFactory::create("whisper_cpp", "models/ggml-base.bin");
if (engine && engine->isAvailable()) { if (engine && engine->isAvailable()) {
engine->setLanguage("fr"); engine->setLanguage("fr");
std::string result = engine->transcribe(audioData); std::string result = engine->transcribe(audioData);
std::cout << "✅ Result: " << result << "\n\n"; std::cout << "✅ Result: " << result << "\n\n";
} else { } else {
std::cout << "❌ Not available (model missing?)\n\n"; std::cout << "❌ Not available (model missing?)\n\n";
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cout << "❌ Error: " << e.what() << "\n\n"; std::cout << "❌ Error: " << e.what() << "\n\n";
} }
} }
// 2. Whisper API // 2. Whisper API
{ {
std::cout << "[2/5] OpenAI Whisper API\n"; std::cout << "[2/5] OpenAI Whisper API\n";
std::cout << "----------------------------\n"; std::cout << "----------------------------\n";
std::string apiKey = getEnvVar("OPENAI_API_KEY"); std::string apiKey = getEnvVar("OPENAI_API_KEY");
if (apiKey.empty()) { if (apiKey.empty()) {
std::cout << "❌ OPENAI_API_KEY not set\n\n"; std::cout << "❌ OPENAI_API_KEY not set\n\n";
} else { } else {
try { try {
auto engine = STTEngineFactory::create("whisper_api", "", apiKey); auto engine = STTEngineFactory::create("whisper_api", "", apiKey);
if (engine && engine->isAvailable()) { if (engine && engine->isAvailable()) {
engine->setLanguage("fr"); engine->setLanguage("fr");
std::string result = engine->transcribeFile(audioFile); std::string result = engine->transcribeFile(audioFile);
std::cout << "✅ Result: " << result << "\n\n"; std::cout << "✅ Result: " << result << "\n\n";
} else { } else {
std::cout << "❌ Not available\n\n"; std::cout << "❌ Not available\n\n";
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cout << "❌ Error: " << e.what() << "\n\n"; std::cout << "❌ Error: " << e.what() << "\n\n";
} }
} }
} }
// 3. Google Speech // 3. Google Speech
{ {
std::cout << "[3/5] Google Speech-to-Text\n"; std::cout << "[3/5] Google Speech-to-Text\n";
std::cout << "----------------------------\n"; std::cout << "----------------------------\n";
std::string apiKey = getEnvVar("GOOGLE_API_KEY"); std::string apiKey = getEnvVar("GOOGLE_API_KEY");
if (apiKey.empty()) { if (apiKey.empty()) {
std::cout << "❌ GOOGLE_API_KEY not set\n\n"; std::cout << "❌ GOOGLE_API_KEY not set\n\n";
} else { } else {
try { try {
auto engine = STTEngineFactory::create("google", "", apiKey); auto engine = STTEngineFactory::create("google", "", apiKey);
if (engine && engine->isAvailable()) { if (engine && engine->isAvailable()) {
engine->setLanguage("fr"); engine->setLanguage("fr");
std::string result = engine->transcribeFile(audioFile); std::string result = engine->transcribeFile(audioFile);
std::cout << "✅ Result: " << result << "\n\n"; std::cout << "✅ Result: " << result << "\n\n";
} else { } else {
std::cout << "❌ Not available\n\n"; std::cout << "❌ Not available\n\n";
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cout << "❌ Error: " << e.what() << "\n\n"; std::cout << "❌ Error: " << e.what() << "\n\n";
} }
} }
} }
// 4. Azure Speech // 4. Azure Speech
{ {
std::cout << "[4/5] Azure Speech-to-Text\n"; std::cout << "[4/5] Azure Speech-to-Text\n";
std::cout << "----------------------------\n"; std::cout << "----------------------------\n";
std::string apiKey = getEnvVar("AZURE_SPEECH_KEY"); std::string apiKey = getEnvVar("AZURE_SPEECH_KEY");
std::string region = getEnvVar("AZURE_SPEECH_REGION"); std::string region = getEnvVar("AZURE_SPEECH_REGION");
if (apiKey.empty() || region.empty()) { if (apiKey.empty() || region.empty()) {
std::cout << "❌ AZURE_SPEECH_KEY or AZURE_SPEECH_REGION not set\n\n"; std::cout << "❌ AZURE_SPEECH_KEY or AZURE_SPEECH_REGION not set\n\n";
} else { } else {
try { try {
auto engine = STTEngineFactory::create("azure", region, apiKey); auto engine = STTEngineFactory::create("azure", region, apiKey);
if (engine && engine->isAvailable()) { if (engine && engine->isAvailable()) {
engine->setLanguage("fr"); engine->setLanguage("fr");
std::string result = engine->transcribeFile(audioFile); std::string result = engine->transcribeFile(audioFile);
std::cout << "✅ Result: " << result << "\n\n"; std::cout << "✅ Result: " << result << "\n\n";
} else { } else {
std::cout << "❌ Not available\n\n"; std::cout << "❌ Not available\n\n";
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cout << "❌ Error: " << e.what() << "\n\n"; std::cout << "❌ Error: " << e.what() << "\n\n";
} }
} }
} }
// 5. Deepgram // 5. Deepgram
{ {
std::cout << "[5/5] Deepgram\n"; std::cout << "[5/5] Deepgram\n";
std::cout << "----------------------------\n"; std::cout << "----------------------------\n";
std::string apiKey = getEnvVar("DEEPGRAM_API_KEY"); std::string apiKey = getEnvVar("DEEPGRAM_API_KEY");
if (apiKey.empty()) { if (apiKey.empty()) {
std::cout << "❌ DEEPGRAM_API_KEY not set\n\n"; std::cout << "❌ DEEPGRAM_API_KEY not set\n\n";
} else { } else {
try { try {
auto engine = STTEngineFactory::create("deepgram", "", apiKey); auto engine = STTEngineFactory::create("deepgram", "", apiKey);
if (engine && engine->isAvailable()) { if (engine && engine->isAvailable()) {
engine->setLanguage("fr"); engine->setLanguage("fr");
std::string result = engine->transcribeFile(audioFile); std::string result = engine->transcribeFile(audioFile);
std::cout << "✅ Result: " << result << "\n\n"; std::cout << "✅ Result: " << result << "\n\n";
} else { } else {
std::cout << "❌ Not available\n\n"; std::cout << "❌ Not available\n\n";
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cout << "❌ Error: " << e.what() << "\n\n"; std::cout << "❌ Error: " << e.what() << "\n\n";
} }
} }
} }
std::cout << "========================================\n"; std::cout << "========================================\n";
std::cout << "Testing complete!\n"; std::cout << "Testing complete!\n";
std::cout << "========================================\n"; std::cout << "========================================\n";
return 0; return 0;
} }

View File

@ -1,176 +1,176 @@
# ============================================================================ # ============================================================================
# AISSIA Integration Tests # AISSIA Integration Tests
# ============================================================================ # ============================================================================
# Fetch Catch2 # Fetch Catch2
include(FetchContent) include(FetchContent)
FetchContent_Declare( FetchContent_Declare(
Catch2 Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.4.0 GIT_TAG v3.4.0
) )
FetchContent_MakeAvailable(Catch2) FetchContent_MakeAvailable(Catch2)
# ============================================================================ # ============================================================================
# Test executable # Test executable
# ============================================================================ # ============================================================================
add_executable(aissia_tests add_executable(aissia_tests
main.cpp main.cpp
# Mocks # Mocks
mocks/MockIO.cpp mocks/MockIO.cpp
# Module sources (needed for testing) # Module sources (needed for testing)
${CMAKE_SOURCE_DIR}/src/modules/SchedulerModule.cpp ${CMAKE_SOURCE_DIR}/src/modules/SchedulerModule.cpp
${CMAKE_SOURCE_DIR}/src/modules/NotificationModule.cpp ${CMAKE_SOURCE_DIR}/src/modules/NotificationModule.cpp
${CMAKE_SOURCE_DIR}/src/modules/MonitoringModule.cpp ${CMAKE_SOURCE_DIR}/src/modules/MonitoringModule.cpp
${CMAKE_SOURCE_DIR}/src/modules/AIModule.cpp ${CMAKE_SOURCE_DIR}/src/modules/AIModule.cpp
${CMAKE_SOURCE_DIR}/src/modules/VoiceModule.cpp ${CMAKE_SOURCE_DIR}/src/modules/VoiceModule.cpp
${CMAKE_SOURCE_DIR}/src/modules/StorageModule.cpp ${CMAKE_SOURCE_DIR}/src/modules/StorageModule.cpp
${CMAKE_SOURCE_DIR}/src/modules/WebModule.cpp ${CMAKE_SOURCE_DIR}/src/modules/WebModule.cpp
# Module tests (70 TI) # Module tests (70 TI)
modules/SchedulerModuleTests.cpp modules/SchedulerModuleTests.cpp
modules/NotificationModuleTests.cpp modules/NotificationModuleTests.cpp
modules/MonitoringModuleTests.cpp modules/MonitoringModuleTests.cpp
modules/AIModuleTests.cpp modules/AIModuleTests.cpp
modules/VoiceModuleTests.cpp modules/VoiceModuleTests.cpp
modules/StorageModuleTests.cpp modules/StorageModuleTests.cpp
modules/WebModuleTests.cpp modules/WebModuleTests.cpp
# MCP tests (50 TI) # MCP tests (50 TI)
mcp/MCPTypesTests.cpp mcp/MCPTypesTests.cpp
mcp/StdioTransportTests.cpp mcp/StdioTransportTests.cpp
mcp/MCPClientTests.cpp mcp/MCPClientTests.cpp
) )
target_link_libraries(aissia_tests PRIVATE target_link_libraries(aissia_tests PRIVATE
Catch2::Catch2WithMain Catch2::Catch2WithMain
GroveEngine::impl GroveEngine::impl
AissiaTools AissiaTools
spdlog::spdlog spdlog::spdlog
) )
# WebModule needs httplib and OpenSSL # WebModule needs httplib and OpenSSL
target_include_directories(aissia_tests PRIVATE target_include_directories(aissia_tests PRIVATE
${httplib_SOURCE_DIR} ${httplib_SOURCE_DIR}
) )
# Link Winsock for httplib on Windows # Link Winsock for httplib on Windows
if(WIN32) if(WIN32)
target_link_libraries(aissia_tests PRIVATE ws2_32) target_link_libraries(aissia_tests PRIVATE ws2_32)
endif() endif()
if(OPENSSL_FOUND) if(OPENSSL_FOUND)
target_link_libraries(aissia_tests PRIVATE OpenSSL::SSL OpenSSL::Crypto) target_link_libraries(aissia_tests PRIVATE OpenSSL::SSL OpenSSL::Crypto)
target_compile_definitions(aissia_tests PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT) target_compile_definitions(aissia_tests PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT)
endif() endif()
# Disable module factory functions during testing # Disable module factory functions during testing
target_compile_definitions(aissia_tests PRIVATE AISSIA_TEST_BUILD) target_compile_definitions(aissia_tests PRIVATE AISSIA_TEST_BUILD)
target_include_directories(aissia_tests PRIVATE target_include_directories(aissia_tests PRIVATE
${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}
) )
# ============================================================================ # ============================================================================
# Copy test fixtures to build directory # Copy test fixtures to build directory
# ============================================================================ # ============================================================================
file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/ file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/
DESTINATION ${CMAKE_BINARY_DIR}/tests/fixtures) DESTINATION ${CMAKE_BINARY_DIR}/tests/fixtures)
# ============================================================================ # ============================================================================
# CTest integration # CTest integration
# ============================================================================ # ============================================================================
include(CTest) include(CTest)
# Note: catch_discover_tests requires running the exe at build time # Note: catch_discover_tests requires running the exe at build time
# which can fail due to missing DLLs. Use manual test registration instead. # which can fail due to missing DLLs. Use manual test registration instead.
add_test(NAME aissia_tests COMMAND aissia_tests) add_test(NAME aissia_tests COMMAND aissia_tests)
# ============================================================================ # ============================================================================
# Custom targets # Custom targets
# ============================================================================ # ============================================================================
# Run all tests # Run all tests
add_custom_target(test_all add_custom_target(test_all
COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure
DEPENDS aissia_tests DEPENDS aissia_tests
COMMENT "Running all integration tests" COMMENT "Running all integration tests"
) )
# Run module tests only # Run module tests only
add_custom_target(test_modules add_custom_target(test_modules
COMMAND $<TARGET_FILE:aissia_tests> "[scheduler],[notification],[monitoring],[ai],[voice],[storage],[web]" COMMAND $<TARGET_FILE:aissia_tests> "[scheduler],[notification],[monitoring],[ai],[voice],[storage],[web]"
DEPENDS aissia_tests DEPENDS aissia_tests
COMMENT "Running module integration tests" COMMENT "Running module integration tests"
) )
# Run MCP tests only # Run MCP tests only
add_custom_target(test_mcp add_custom_target(test_mcp
COMMAND $<TARGET_FILE:aissia_tests> "[mcp]" COMMAND $<TARGET_FILE:aissia_tests> "[mcp]"
DEPENDS aissia_tests DEPENDS aissia_tests
COMMENT "Running MCP integration tests" COMMENT "Running MCP integration tests"
) )
# ============================================================================ # ============================================================================
# Integration Test Modules (Dynamic .so files) # Integration Test Modules (Dynamic .so files)
# ============================================================================ # ============================================================================
# Helper macro to create integration test modules # Helper macro to create integration test modules
macro(add_integration_test TEST_NAME) macro(add_integration_test TEST_NAME)
add_library(${TEST_NAME} SHARED add_library(${TEST_NAME} SHARED
integration/${TEST_NAME}.cpp integration/${TEST_NAME}.cpp
) )
target_include_directories(${TEST_NAME} PRIVATE target_include_directories(${TEST_NAME} PRIVATE
${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src
) )
target_link_libraries(${TEST_NAME} PRIVATE target_link_libraries(${TEST_NAME} PRIVATE
GroveEngine::impl GroveEngine::impl
spdlog::spdlog spdlog::spdlog
) )
set_target_properties(${TEST_NAME} PROPERTIES set_target_properties(${TEST_NAME} PROPERTIES
PREFIX "" PREFIX ""
LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/tests/integration LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/tests/integration
) )
endmacro() endmacro()
# Individual integration test modules (will be added as we create them) # Individual integration test modules (will be added as we create them)
# Phase 2: MCP Tests # Phase 2: MCP Tests
add_integration_test(IT_001_GetCurrentTime) add_integration_test(IT_001_GetCurrentTime)
add_integration_test(IT_002_FileSystemWrite) add_integration_test(IT_002_FileSystemWrite)
add_integration_test(IT_003_FileSystemRead) add_integration_test(IT_003_FileSystemRead)
add_integration_test(IT_004_MCPToolsList) add_integration_test(IT_004_MCPToolsList)
# Phase 3: Flow Tests # Phase 3: Flow Tests
add_integration_test(IT_005_VoiceToAI) add_integration_test(IT_005_VoiceToAI)
add_integration_test(IT_006_AIToLLM) add_integration_test(IT_006_AIToLLM)
add_integration_test(IT_007_StorageWrite) add_integration_test(IT_007_StorageWrite)
add_integration_test(IT_008_StorageRead) add_integration_test(IT_008_StorageRead)
# Phase 4: End-to-End Test # Phase 4: End-to-End Test
add_integration_test(IT_009_FullConversationLoop) add_integration_test(IT_009_FullConversationLoop)
# Phase 5: Module Tests # Phase 5: Module Tests
add_integration_test(IT_010_SchedulerHyperfocus) add_integration_test(IT_010_SchedulerHyperfocus)
add_integration_test(IT_011_NotificationAlert) add_integration_test(IT_011_NotificationAlert)
add_integration_test(IT_012_MonitoringActivity) add_integration_test(IT_012_MonitoringActivity)
add_integration_test(IT_013_WebRequest) add_integration_test(IT_013_WebRequest)
# Custom target to build all integration tests # Custom target to build all integration tests
add_custom_target(integration_tests add_custom_target(integration_tests
DEPENDS DEPENDS
IT_001_GetCurrentTime IT_001_GetCurrentTime
IT_002_FileSystemWrite IT_002_FileSystemWrite
IT_003_FileSystemRead IT_003_FileSystemRead
IT_004_MCPToolsList IT_004_MCPToolsList
IT_005_VoiceToAI IT_005_VoiceToAI
IT_006_AIToLLM IT_006_AIToLLM
IT_007_StorageWrite IT_007_StorageWrite
IT_008_StorageRead IT_008_StorageRead
IT_009_FullConversationLoop IT_009_FullConversationLoop
IT_010_SchedulerHyperfocus IT_010_SchedulerHyperfocus
IT_011_NotificationAlert IT_011_NotificationAlert
IT_012_MonitoringActivity IT_012_MonitoringActivity
IT_013_WebRequest IT_013_WebRequest
COMMENT "Building all integration test modules" COMMENT "Building all integration test modules"
) )

View File

@ -1,34 +1,34 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Simple JSON-RPC echo server for testing StdioTransport. Simple JSON-RPC echo server for testing StdioTransport.
Echoes back the params of any request as the result. Echoes back the params of any request as the result.
""" """
import json import json
import sys import sys
def main(): def main():
while True: while True:
try: try:
line = sys.stdin.readline() line = sys.stdin.readline()
if not line: if not line:
break break
request = json.loads(line.strip()) request = json.loads(line.strip())
response = { response = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": request.get("id"), "id": request.get("id"),
"result": request.get("params", {}) "result": request.get("params", {})
} }
sys.stdout.write(json.dumps(response) + "\n") sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush() sys.stdout.flush()
except json.JSONDecodeError: except json.JSONDecodeError:
# Invalid JSON, ignore # Invalid JSON, ignore
pass pass
except Exception: except Exception:
# Other errors, continue # Other errors, continue
pass pass
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,19 +1,19 @@
{ {
"servers": { "servers": {
"mock_server": { "mock_server": {
"command": "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", "command": "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
"args": ["-u", "tests/fixtures/mock_mcp_server.py"], "args": ["-u", "tests/fixtures/mock_mcp_server.py"],
"enabled": true "enabled": true
}, },
"disabled_server": { "disabled_server": {
"command": "nonexistent_command", "command": "nonexistent_command",
"args": [], "args": [],
"enabled": false "enabled": false
}, },
"echo_server": { "echo_server": {
"command": "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", "command": "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
"args": ["-u", "tests/fixtures/echo_server.py"], "args": ["-u", "tests/fixtures/echo_server.py"],
"enabled": true "enabled": true
} }
} }
} }

View File

@ -1,136 +1,136 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Mock MCP server for integration testing. Mock MCP server for integration testing.
Implements the MCP protocol (initialize, tools/list, tools/call). Implements the MCP protocol (initialize, tools/list, tools/call).
""" """
import json import json
import sys import sys
import os import os
# Tools exposed by this mock server # Tools exposed by this mock server
TOOLS = [ TOOLS = [
{ {
"name": "test_tool", "name": "test_tool",
"description": "A test tool that echoes its input", "description": "A test tool that echoes its input",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"message": {"type": "string", "description": "Message to echo"} "message": {"type": "string", "description": "Message to echo"}
}, },
"required": ["message"] "required": ["message"]
} }
}, },
{ {
"name": "get_time", "name": "get_time",
"description": "Returns the current server time", "description": "Returns the current server time",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": {} "properties": {}
} }
} }
] ]
def handle_initialize(params): def handle_initialize(params):
"""Handle initialize request""" """Handle initialize request"""
return { return {
"protocolVersion": "2024-11-05", "protocolVersion": "2024-11-05",
"capabilities": { "capabilities": {
"tools": {} "tools": {}
}, },
"serverInfo": { "serverInfo": {
"name": "MockMCPServer", "name": "MockMCPServer",
"version": "1.0.0" "version": "1.0.0"
} }
} }
def handle_tools_list(params): def handle_tools_list(params):
"""Handle tools/list request""" """Handle tools/list request"""
return {"tools": TOOLS} return {"tools": TOOLS}
def handle_tools_call(params): def handle_tools_call(params):
"""Handle tools/call request""" """Handle tools/call request"""
tool_name = params.get("name", "") tool_name = params.get("name", "")
arguments = params.get("arguments", {}) arguments = params.get("arguments", {})
if tool_name == "test_tool": if tool_name == "test_tool":
message = arguments.get("message", "no message") message = arguments.get("message", "no message")
return { return {
"content": [ "content": [
{"type": "text", "text": f"Echo: {message}"} {"type": "text", "text": f"Echo: {message}"}
] ]
} }
elif tool_name == "get_time": elif tool_name == "get_time":
import datetime import datetime
return { return {
"content": [ "content": [
{"type": "text", "text": datetime.datetime.now().isoformat()} {"type": "text", "text": datetime.datetime.now().isoformat()}
] ]
} }
else: else:
return { return {
"content": [ "content": [
{"type": "text", "text": f"Unknown tool: {tool_name}"} {"type": "text", "text": f"Unknown tool: {tool_name}"}
], ],
"isError": True "isError": True
} }
def handle_request(request): def handle_request(request):
"""Route request to appropriate handler""" """Route request to appropriate handler"""
method = request.get("method", "") method = request.get("method", "")
handlers = { handlers = {
"initialize": handle_initialize, "initialize": handle_initialize,
"tools/list": handle_tools_list, "tools/list": handle_tools_list,
"tools/call": handle_tools_call, "tools/call": handle_tools_call,
} }
handler = handlers.get(method) handler = handlers.get(method)
if handler: if handler:
return handler(request.get("params", {})) return handler(request.get("params", {}))
else: else:
return {"error": {"code": -32601, "message": f"Method not found: {method}"}} return {"error": {"code": -32601, "message": f"Method not found: {method}"}}
def main(): def main():
while True: while True:
try: try:
line = sys.stdin.readline() line = sys.stdin.readline()
if not line: if not line:
break break
request = json.loads(line.strip()) request = json.loads(line.strip())
result = handle_request(request) result = handle_request(request)
response = { response = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": request.get("id") "id": request.get("id")
} }
if "error" in result: if "error" in result:
response["error"] = result["error"] response["error"] = result["error"]
else: else:
response["result"] = result response["result"] = result
sys.stdout.write(json.dumps(response) + "\n") sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush() sys.stdout.flush()
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
error_response = { error_response = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": None, "id": None,
"error": {"code": -32700, "message": f"Parse error: {str(e)}"} "error": {"code": -32700, "message": f"Parse error: {str(e)}"}
} }
sys.stdout.write(json.dumps(error_response) + "\n") sys.stdout.write(json.dumps(error_response) + "\n")
sys.stdout.flush() sys.stdout.flush()
except Exception as e: except Exception as e:
# Log to stderr for debugging # Log to stderr for debugging
sys.stderr.write(f"Error: {str(e)}\n") sys.stderr.write(f"Error: {str(e)}\n")
sys.stderr.flush() sys.stderr.flush()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,8 +1,8 @@
// AISSIA Integration Tests - Entry Point // AISSIA Integration Tests - Entry Point
// Using Catch2 v3 with main provided by Catch2::Catch2WithMain // Using Catch2 v3 with main provided by Catch2::Catch2WithMain
// This file is intentionally minimal. // This file is intentionally minimal.
// Catch2WithMain provides the main() function automatically. // Catch2WithMain provides the main() function automatically.
// Include common test utilities // Include common test utilities
#include "utils/TestHelpers.hpp" #include "utils/TestHelpers.hpp"

View File

@ -1,392 +1,392 @@
/** /**
* @file MCPClientTests.cpp * @file MCPClientTests.cpp
* @brief Integration tests for MCPClient (15 TI) * @brief Integration tests for MCPClient (15 TI)
*/ */
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include "shared/mcp/MCPClient.hpp" #include "shared/mcp/MCPClient.hpp"
#include "mocks/MockTransport.hpp" #include "mocks/MockTransport.hpp"
#include <fstream> #include <fstream>
#include <filesystem> #include <filesystem>
using namespace aissia::mcp; using namespace aissia::mcp;
using namespace aissia::tests; using namespace aissia::tests;
using json = nlohmann::json; using json = nlohmann::json;
// ============================================================================ // ============================================================================
// Helper: Create test config file // Helper: Create test config file
// ============================================================================ // ============================================================================
std::string createTestConfigFile(const json& config) { std::string createTestConfigFile(const json& config) {
std::string path = "test_mcp_config.json"; std::string path = "test_mcp_config.json";
std::ofstream file(path); std::ofstream file(path);
file << config.dump(2); file << config.dump(2);
file.close(); file.close();
return path; return path;
} }
void cleanupTestConfigFile(const std::string& path) { void cleanupTestConfigFile(const std::string& path) {
std::filesystem::remove(path); std::filesystem::remove(path);
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_001: Load config valid // TI_CLIENT_001: Load config valid
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_001_LoadConfigValid", "[mcp][client]") { TEST_CASE("TI_CLIENT_001_LoadConfigValid", "[mcp][client]") {
json config = { json config = {
{"servers", { {"servers", {
{"test_server", { {"test_server", {
{"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"},
{"args", json::array({"server.py"})}, {"args", json::array({"server.py"})},
{"enabled", true} {"enabled", true}
}} }}
}} }}
}; };
auto path = createTestConfigFile(config); auto path = createTestConfigFile(config);
MCPClient client; MCPClient client;
bool loaded = client.loadConfig(path); bool loaded = client.loadConfig(path);
REQUIRE(loaded == true); REQUIRE(loaded == true);
cleanupTestConfigFile(path); cleanupTestConfigFile(path);
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_002: Load config invalid // TI_CLIENT_002: Load config invalid
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_002_LoadConfigInvalid", "[mcp][client]") { TEST_CASE("TI_CLIENT_002_LoadConfigInvalid", "[mcp][client]") {
// Create file with invalid JSON // Create file with invalid JSON
std::string path = "invalid_config.json"; std::string path = "invalid_config.json";
std::ofstream file(path); std::ofstream file(path);
file << "{ invalid json }"; file << "{ invalid json }";
file.close(); file.close();
MCPClient client; MCPClient client;
bool loaded = client.loadConfig(path); bool loaded = client.loadConfig(path);
REQUIRE(loaded == false); REQUIRE(loaded == false);
cleanupTestConfigFile(path); cleanupTestConfigFile(path);
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_003: Load config missing file // TI_CLIENT_003: Load config missing file
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_003_LoadConfigMissingFile", "[mcp][client]") { TEST_CASE("TI_CLIENT_003_LoadConfigMissingFile", "[mcp][client]") {
MCPClient client; MCPClient client;
bool loaded = client.loadConfig("nonexistent_file.json"); bool loaded = client.loadConfig("nonexistent_file.json");
REQUIRE(loaded == false); REQUIRE(loaded == false);
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_004: ConnectAll starts servers // TI_CLIENT_004: ConnectAll starts servers
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_004_ConnectAllStartsServers", "[mcp][client]") { TEST_CASE("TI_CLIENT_004_ConnectAllStartsServers", "[mcp][client]") {
// Use the real mock MCP server fixture // Use the real mock MCP server fixture
MCPClient client; MCPClient client;
bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json");
if (loaded) { if (loaded) {
int connected = client.connectAll(); int connected = client.connectAll();
// Should connect to enabled servers // Should connect to enabled servers
REQUIRE(connected >= 0); REQUIRE(connected >= 0);
client.disconnectAll(); client.disconnectAll();
} else { } else {
// Skip if fixture not available // Skip if fixture not available
SUCCEED(); SUCCEED();
} }
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_005: ConnectAll skips disabled // TI_CLIENT_005: ConnectAll skips disabled
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_005_ConnectAllSkipsDisabled", "[mcp][client]") { TEST_CASE("TI_CLIENT_005_ConnectAllSkipsDisabled", "[mcp][client]") {
json config = { json config = {
{"servers", { {"servers", {
{"enabled_server", { {"enabled_server", {
{"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"},
{"args", json::array({"tests/fixtures/echo_server.py"})}, {"args", json::array({"tests/fixtures/echo_server.py"})},
{"enabled", true} {"enabled", true}
}}, }},
{"disabled_server", { {"disabled_server", {
{"command", "nonexistent"}, {"command", "nonexistent"},
{"enabled", false} {"enabled", false}
}} }}
}} }}
}; };
auto path = createTestConfigFile(config); auto path = createTestConfigFile(config);
MCPClient client; MCPClient client;
client.loadConfig(path); client.loadConfig(path);
int connected = client.connectAll(); int connected = client.connectAll();
// disabled_server should not be connected // disabled_server should not be connected
REQUIRE(client.isConnected("disabled_server") == false); REQUIRE(client.isConnected("disabled_server") == false);
client.disconnectAll(); client.disconnectAll();
cleanupTestConfigFile(path); cleanupTestConfigFile(path);
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_006: Connect single server // TI_CLIENT_006: Connect single server
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_006_ConnectSingleServer", "[mcp][client]") { TEST_CASE("TI_CLIENT_006_ConnectSingleServer", "[mcp][client]") {
json config = { json config = {
{"servers", { {"servers", {
{"server1", { {"server1", {
{"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"},
{"args", json::array({"tests/fixtures/echo_server.py"})}, {"args", json::array({"tests/fixtures/echo_server.py"})},
{"enabled", true} {"enabled", true}
}}, }},
{"server2", { {"server2", {
{"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"},
{"args", json::array({"tests/fixtures/echo_server.py"})}, {"args", json::array({"tests/fixtures/echo_server.py"})},
{"enabled", true} {"enabled", true}
}} }}
}} }}
}; };
auto path = createTestConfigFile(config); auto path = createTestConfigFile(config);
MCPClient client; MCPClient client;
client.loadConfig(path); client.loadConfig(path);
// Connect only server1 // Connect only server1
bool connected = client.connect("server1"); bool connected = client.connect("server1");
REQUIRE(connected == true); REQUIRE(connected == true);
REQUIRE(client.isConnected("server1") == true); REQUIRE(client.isConnected("server1") == true);
REQUIRE(client.isConnected("server2") == false); REQUIRE(client.isConnected("server2") == false);
client.disconnectAll(); client.disconnectAll();
cleanupTestConfigFile(path); cleanupTestConfigFile(path);
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_007: Disconnect single server // TI_CLIENT_007: Disconnect single server
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_007_DisconnectSingleServer", "[mcp][client]") { TEST_CASE("TI_CLIENT_007_DisconnectSingleServer", "[mcp][client]") {
json config = { json config = {
{"servers", { {"servers", {
{"server1", { {"server1", {
{"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"},
{"args", json::array({"tests/fixtures/echo_server.py"})}, {"args", json::array({"tests/fixtures/echo_server.py"})},
{"enabled", true} {"enabled", true}
}} }}
}} }}
}; };
auto path = createTestConfigFile(config); auto path = createTestConfigFile(config);
MCPClient client; MCPClient client;
client.loadConfig(path); client.loadConfig(path);
client.connect("server1"); client.connect("server1");
REQUIRE(client.isConnected("server1") == true); REQUIRE(client.isConnected("server1") == true);
client.disconnect("server1"); client.disconnect("server1");
REQUIRE(client.isConnected("server1") == false); REQUIRE(client.isConnected("server1") == false);
cleanupTestConfigFile(path); cleanupTestConfigFile(path);
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_008: DisconnectAll cleans up // TI_CLIENT_008: DisconnectAll cleans up
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_008_DisconnectAllCleansUp", "[mcp][client]") { TEST_CASE("TI_CLIENT_008_DisconnectAllCleansUp", "[mcp][client]") {
json config = { json config = {
{"servers", { {"servers", {
{"server1", { {"server1", {
{"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"},
{"args", json::array({"tests/fixtures/echo_server.py"})}, {"args", json::array({"tests/fixtures/echo_server.py"})},
{"enabled", true} {"enabled", true}
}}, }},
{"server2", { {"server2", {
{"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"},
{"args", json::array({"tests/fixtures/echo_server.py"})}, {"args", json::array({"tests/fixtures/echo_server.py"})},
{"enabled", true} {"enabled", true}
}} }}
}} }}
}; };
auto path = createTestConfigFile(config); auto path = createTestConfigFile(config);
MCPClient client; MCPClient client;
client.loadConfig(path); client.loadConfig(path);
client.connectAll(); client.connectAll();
client.disconnectAll(); client.disconnectAll();
REQUIRE(client.isConnected("server1") == false); REQUIRE(client.isConnected("server1") == false);
REQUIRE(client.isConnected("server2") == false); REQUIRE(client.isConnected("server2") == false);
REQUIRE(client.getConnectedServers().empty() == true); REQUIRE(client.getConnectedServers().empty() == true);
cleanupTestConfigFile(path); cleanupTestConfigFile(path);
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_009: ListAllTools aggregates // TI_CLIENT_009: ListAllTools aggregates
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_009_ListAllToolsAggregates", "[mcp][client]") { TEST_CASE("TI_CLIENT_009_ListAllToolsAggregates", "[mcp][client]") {
MCPClient client; MCPClient client;
bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json");
if (loaded) { if (loaded) {
client.connectAll(); client.connectAll();
auto tools = client.listAllTools(); auto tools = client.listAllTools();
// Should have tools from mock server // Should have tools from mock server
REQUIRE(tools.size() >= 0); REQUIRE(tools.size() >= 0);
client.disconnectAll(); client.disconnectAll();
} else { } else {
SUCCEED(); SUCCEED();
} }
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_010: Tool name prefixed // TI_CLIENT_010: Tool name prefixed
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_010_ToolNamePrefixed", "[mcp][client]") { TEST_CASE("TI_CLIENT_010_ToolNamePrefixed", "[mcp][client]") {
MCPClient client; MCPClient client;
bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json");
if (loaded) { if (loaded) {
client.connectAll(); client.connectAll();
auto tools = client.listAllTools(); auto tools = client.listAllTools();
bool hasPrefix = false; bool hasPrefix = false;
for (const auto& tool : tools) { for (const auto& tool : tools) {
if (tool.name.find(":") != std::string::npos) { if (tool.name.find(":") != std::string::npos) {
hasPrefix = true; hasPrefix = true;
break; break;
} }
} }
if (!tools.empty()) { if (!tools.empty()) {
REQUIRE(hasPrefix == true); REQUIRE(hasPrefix == true);
} }
client.disconnectAll(); client.disconnectAll();
} else { } else {
SUCCEED(); SUCCEED();
} }
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_011: CallTool routes to server // TI_CLIENT_011: CallTool routes to server
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_011_CallToolRoutesToServer", "[mcp][client]") { TEST_CASE("TI_CLIENT_011_CallToolRoutesToServer", "[mcp][client]") {
MCPClient client; MCPClient client;
bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json");
if (loaded) { if (loaded) {
client.connectAll(); client.connectAll();
auto tools = client.listAllTools(); auto tools = client.listAllTools();
if (!tools.empty()) { if (!tools.empty()) {
// Call the first available tool // Call the first available tool
auto result = client.callTool(tools[0].name, json::object()); auto result = client.callTool(tools[0].name, json::object());
// Should get some result (success or error) // Should get some result (success or error)
REQUIRE(result.content.size() >= 0); REQUIRE(result.content.size() >= 0);
} }
client.disconnectAll(); client.disconnectAll();
} else { } else {
SUCCEED(); SUCCEED();
} }
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_012: CallTool invalid name // TI_CLIENT_012: CallTool invalid name
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_012_CallToolInvalidName", "[mcp][client]") { TEST_CASE("TI_CLIENT_012_CallToolInvalidName", "[mcp][client]") {
MCPClient client; MCPClient client;
client.loadConfig("tests/fixtures/mock_mcp.json"); client.loadConfig("tests/fixtures/mock_mcp.json");
client.connectAll(); client.connectAll();
auto result = client.callTool("nonexistent:tool", json::object()); auto result = client.callTool("nonexistent:tool", json::object());
REQUIRE(result.isError == true); REQUIRE(result.isError == true);
client.disconnectAll(); client.disconnectAll();
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_013: CallTool disconnected server // TI_CLIENT_013: CallTool disconnected server
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_013_CallToolDisconnectedServer", "[mcp][client]") { TEST_CASE("TI_CLIENT_013_CallToolDisconnectedServer", "[mcp][client]") {
MCPClient client; MCPClient client;
// Don't connect any servers // Don't connect any servers
auto result = client.callTool("server:tool", json::object()); auto result = client.callTool("server:tool", json::object());
REQUIRE(result.isError == true); REQUIRE(result.isError == true);
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_014: ToolCount accurate // TI_CLIENT_014: ToolCount accurate
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_014_ToolCountAccurate", "[mcp][client]") { TEST_CASE("TI_CLIENT_014_ToolCountAccurate", "[mcp][client]") {
MCPClient client; MCPClient client;
bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json"); bool loaded = client.loadConfig("tests/fixtures/mock_mcp.json");
if (loaded) { if (loaded) {
client.connectAll(); client.connectAll();
size_t count = client.toolCount(); size_t count = client.toolCount();
auto tools = client.listAllTools(); auto tools = client.listAllTools();
REQUIRE(count == tools.size()); REQUIRE(count == tools.size());
client.disconnectAll(); client.disconnectAll();
} else { } else {
SUCCEED(); SUCCEED();
} }
} }
// ============================================================================ // ============================================================================
// TI_CLIENT_015: IsConnected accurate // TI_CLIENT_015: IsConnected accurate
// ============================================================================ // ============================================================================
TEST_CASE("TI_CLIENT_015_IsConnectedAccurate", "[mcp][client]") { TEST_CASE("TI_CLIENT_015_IsConnectedAccurate", "[mcp][client]") {
json config = { json config = {
{"servers", { {"servers", {
{"test_server", { {"test_server", {
{"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"}, {"command", "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"},
{"args", json::array({"tests/fixtures/echo_server.py"})}, {"args", json::array({"tests/fixtures/echo_server.py"})},
{"enabled", true} {"enabled", true}
}} }}
}} }}
}; };
auto path = createTestConfigFile(config); auto path = createTestConfigFile(config);
MCPClient client; MCPClient client;
client.loadConfig(path); client.loadConfig(path);
// Not connected yet // Not connected yet
REQUIRE(client.isConnected("test_server") == false); REQUIRE(client.isConnected("test_server") == false);
// Connect // Connect
client.connect("test_server"); client.connect("test_server");
REQUIRE(client.isConnected("test_server") == true); REQUIRE(client.isConnected("test_server") == true);
// Disconnect // Disconnect
client.disconnect("test_server"); client.disconnect("test_server");
REQUIRE(client.isConnected("test_server") == false); REQUIRE(client.isConnected("test_server") == false);
cleanupTestConfigFile(path); cleanupTestConfigFile(path);
} }

View File

@ -1,298 +1,298 @@
/** /**
* @file MCPTypesTests.cpp * @file MCPTypesTests.cpp
* @brief Integration tests for MCP Types (15 TI) * @brief Integration tests for MCP Types (15 TI)
*/ */
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include "shared/mcp/MCPTypes.hpp" #include "shared/mcp/MCPTypes.hpp"
using namespace aissia::mcp; using namespace aissia::mcp;
using json = nlohmann::json; using json = nlohmann::json;
// ============================================================================ // ============================================================================
// TI_TYPES_001: MCPTool toJson // TI_TYPES_001: MCPTool toJson
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_001_MCPToolToJson", "[mcp][types]") { TEST_CASE("TI_TYPES_001_MCPToolToJson", "[mcp][types]") {
MCPTool tool; MCPTool tool;
tool.name = "read_file"; tool.name = "read_file";
tool.description = "Read a file from the filesystem"; tool.description = "Read a file from the filesystem";
tool.inputSchema = { tool.inputSchema = {
{"type", "object"}, {"type", "object"},
{"properties", { {"properties", {
{"path", {{"type", "string"}}} {"path", {{"type", "string"}}}
}}, }},
{"required", json::array({"path"})} {"required", json::array({"path"})}
}; };
json j = tool.toJson(); json j = tool.toJson();
REQUIRE(j["name"] == "read_file"); REQUIRE(j["name"] == "read_file");
REQUIRE(j["description"] == "Read a file from the filesystem"); REQUIRE(j["description"] == "Read a file from the filesystem");
REQUIRE(j["inputSchema"]["type"] == "object"); REQUIRE(j["inputSchema"]["type"] == "object");
REQUIRE(j["inputSchema"]["properties"]["path"]["type"] == "string"); REQUIRE(j["inputSchema"]["properties"]["path"]["type"] == "string");
} }
// ============================================================================ // ============================================================================
// TI_TYPES_002: MCPTool fromJson // TI_TYPES_002: MCPTool fromJson
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_002_MCPToolFromJson", "[mcp][types]") { TEST_CASE("TI_TYPES_002_MCPToolFromJson", "[mcp][types]") {
json j = { json j = {
{"name", "write_file"}, {"name", "write_file"},
{"description", "Write content to a file"}, {"description", "Write content to a file"},
{"inputSchema", { {"inputSchema", {
{"type", "object"}, {"type", "object"},
{"properties", { {"properties", {
{"path", {{"type", "string"}}}, {"path", {{"type", "string"}}},
{"content", {{"type", "string"}}} {"content", {{"type", "string"}}}
}} }}
}} }}
}; };
auto tool = MCPTool::fromJson(j); auto tool = MCPTool::fromJson(j);
REQUIRE(tool.name == "write_file"); REQUIRE(tool.name == "write_file");
REQUIRE(tool.description == "Write content to a file"); REQUIRE(tool.description == "Write content to a file");
REQUIRE(tool.inputSchema["type"] == "object"); REQUIRE(tool.inputSchema["type"] == "object");
} }
// ============================================================================ // ============================================================================
// TI_TYPES_003: MCPTool fromJson with missing fields // TI_TYPES_003: MCPTool fromJson with missing fields
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_003_MCPToolFromJsonMissingFields", "[mcp][types]") { TEST_CASE("TI_TYPES_003_MCPToolFromJsonMissingFields", "[mcp][types]") {
json j = {{"name", "minimal_tool"}}; json j = {{"name", "minimal_tool"}};
auto tool = MCPTool::fromJson(j); auto tool = MCPTool::fromJson(j);
REQUIRE(tool.name == "minimal_tool"); REQUIRE(tool.name == "minimal_tool");
REQUIRE(tool.description == ""); REQUIRE(tool.description == "");
REQUIRE(tool.inputSchema.is_object()); REQUIRE(tool.inputSchema.is_object());
} }
// ============================================================================ // ============================================================================
// TI_TYPES_004: MCPResource fromJson // TI_TYPES_004: MCPResource fromJson
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_004_MCPResourceFromJson", "[mcp][types]") { TEST_CASE("TI_TYPES_004_MCPResourceFromJson", "[mcp][types]") {
json j = { json j = {
{"uri", "file:///home/user/doc.txt"}, {"uri", "file:///home/user/doc.txt"},
{"name", "Document"}, {"name", "Document"},
{"description", "A text document"}, {"description", "A text document"},
{"mimeType", "text/plain"} {"mimeType", "text/plain"}
}; };
auto resource = MCPResource::fromJson(j); auto resource = MCPResource::fromJson(j);
REQUIRE(resource.uri == "file:///home/user/doc.txt"); REQUIRE(resource.uri == "file:///home/user/doc.txt");
REQUIRE(resource.name == "Document"); REQUIRE(resource.name == "Document");
REQUIRE(resource.description == "A text document"); REQUIRE(resource.description == "A text document");
REQUIRE(resource.mimeType == "text/plain"); REQUIRE(resource.mimeType == "text/plain");
} }
// ============================================================================ // ============================================================================
// TI_TYPES_005: MCPToolResult toJson // TI_TYPES_005: MCPToolResult toJson
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_005_MCPToolResultToJson", "[mcp][types]") { TEST_CASE("TI_TYPES_005_MCPToolResultToJson", "[mcp][types]") {
MCPToolResult result; MCPToolResult result;
result.content = { result.content = {
{{"type", "text"}, {"text", "File contents here"}}, {{"type", "text"}, {"text", "File contents here"}},
{{"type", "text"}, {"text", "More content"}} {{"type", "text"}, {"text", "More content"}}
}; };
result.isError = false; result.isError = false;
json j = result.toJson(); json j = result.toJson();
REQUIRE(j["content"].size() == 2); REQUIRE(j["content"].size() == 2);
REQUIRE(j["content"][0]["type"] == "text"); REQUIRE(j["content"][0]["type"] == "text");
REQUIRE(j["isError"] == false); REQUIRE(j["isError"] == false);
} }
// ============================================================================ // ============================================================================
// TI_TYPES_006: MCPCapabilities fromJson // TI_TYPES_006: MCPCapabilities fromJson
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_006_MCPCapabilitiesFromJson", "[mcp][types]") { TEST_CASE("TI_TYPES_006_MCPCapabilitiesFromJson", "[mcp][types]") {
json j = { json j = {
{"tools", json::object()}, {"tools", json::object()},
{"resources", {{"subscribe", true}}}, {"resources", {{"subscribe", true}}},
{"prompts", json::object()} {"prompts", json::object()}
}; };
auto caps = MCPCapabilities::fromJson(j); auto caps = MCPCapabilities::fromJson(j);
REQUIRE(caps.hasTools == true); REQUIRE(caps.hasTools == true);
REQUIRE(caps.hasResources == true); REQUIRE(caps.hasResources == true);
REQUIRE(caps.hasPrompts == true); REQUIRE(caps.hasPrompts == true);
} }
// ============================================================================ // ============================================================================
// TI_TYPES_007: MCPCapabilities empty // TI_TYPES_007: MCPCapabilities empty
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_007_MCPCapabilitiesEmpty", "[mcp][types]") { TEST_CASE("TI_TYPES_007_MCPCapabilitiesEmpty", "[mcp][types]") {
json j = json::object(); json j = json::object();
auto caps = MCPCapabilities::fromJson(j); auto caps = MCPCapabilities::fromJson(j);
REQUIRE(caps.hasTools == false); REQUIRE(caps.hasTools == false);
REQUIRE(caps.hasResources == false); REQUIRE(caps.hasResources == false);
REQUIRE(caps.hasPrompts == false); REQUIRE(caps.hasPrompts == false);
} }
// ============================================================================ // ============================================================================
// TI_TYPES_008: MCPServerInfo fromJson // TI_TYPES_008: MCPServerInfo fromJson
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_008_MCPServerInfoFromJson", "[mcp][types]") { TEST_CASE("TI_TYPES_008_MCPServerInfoFromJson", "[mcp][types]") {
json j = { json j = {
{"name", "filesystem-server"}, {"name", "filesystem-server"},
{"version", "1.2.3"}, {"version", "1.2.3"},
{"capabilities", { {"capabilities", {
{"tools", json::object()} {"tools", json::object()}
}} }}
}; };
auto info = MCPServerInfo::fromJson(j); auto info = MCPServerInfo::fromJson(j);
REQUIRE(info.name == "filesystem-server"); REQUIRE(info.name == "filesystem-server");
REQUIRE(info.version == "1.2.3"); REQUIRE(info.version == "1.2.3");
REQUIRE(info.capabilities.hasTools == true); REQUIRE(info.capabilities.hasTools == true);
} }
// ============================================================================ // ============================================================================
// TI_TYPES_009: JsonRpcRequest toJson // TI_TYPES_009: JsonRpcRequest toJson
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_009_JsonRpcRequestToJson", "[mcp][types]") { TEST_CASE("TI_TYPES_009_JsonRpcRequestToJson", "[mcp][types]") {
JsonRpcRequest request; JsonRpcRequest request;
request.id = 42; request.id = 42;
request.method = "tools/call"; request.method = "tools/call";
request.params = {{"name", "read_file"}, {"arguments", {{"path", "/tmp/test"}}}}; request.params = {{"name", "read_file"}, {"arguments", {{"path", "/tmp/test"}}}};
json j = request.toJson(); json j = request.toJson();
REQUIRE(j["jsonrpc"] == "2.0"); REQUIRE(j["jsonrpc"] == "2.0");
REQUIRE(j["id"] == 42); REQUIRE(j["id"] == 42);
REQUIRE(j["method"] == "tools/call"); REQUIRE(j["method"] == "tools/call");
REQUIRE(j["params"]["name"] == "read_file"); REQUIRE(j["params"]["name"] == "read_file");
} }
// ============================================================================ // ============================================================================
// TI_TYPES_010: JsonRpcResponse fromJson // TI_TYPES_010: JsonRpcResponse fromJson
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_010_JsonRpcResponseFromJson", "[mcp][types]") { TEST_CASE("TI_TYPES_010_JsonRpcResponseFromJson", "[mcp][types]") {
json j = { json j = {
{"jsonrpc", "2.0"}, {"jsonrpc", "2.0"},
{"id", 42}, {"id", 42},
{"result", {{"tools", json::array()}}} {"result", {{"tools", json::array()}}}
}; };
auto response = JsonRpcResponse::fromJson(j); auto response = JsonRpcResponse::fromJson(j);
REQUIRE(response.jsonrpc == "2.0"); REQUIRE(response.jsonrpc == "2.0");
REQUIRE(response.id == 42); REQUIRE(response.id == 42);
REQUIRE(response.result.has_value()); REQUIRE(response.result.has_value());
REQUIRE(response.result.value()["tools"].is_array()); REQUIRE(response.result.value()["tools"].is_array());
} }
// ============================================================================ // ============================================================================
// TI_TYPES_011: JsonRpcResponse isError // TI_TYPES_011: JsonRpcResponse isError
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_011_JsonRpcResponseIsError", "[mcp][types]") { TEST_CASE("TI_TYPES_011_JsonRpcResponseIsError", "[mcp][types]") {
json errorJson = { json errorJson = {
{"jsonrpc", "2.0"}, {"jsonrpc", "2.0"},
{"id", 1}, {"id", 1},
{"error", {{"code", -32600}, {"message", "Invalid Request"}}} {"error", {{"code", -32600}, {"message", "Invalid Request"}}}
}; };
auto response = JsonRpcResponse::fromJson(errorJson); auto response = JsonRpcResponse::fromJson(errorJson);
REQUIRE(response.isError() == true); REQUIRE(response.isError() == true);
REQUIRE(response.error.has_value()); REQUIRE(response.error.has_value());
REQUIRE(response.error.value()["code"] == -32600); REQUIRE(response.error.value()["code"] == -32600);
REQUIRE(response.error.value()["message"] == "Invalid Request"); REQUIRE(response.error.value()["message"] == "Invalid Request");
} }
// ============================================================================ // ============================================================================
// TI_TYPES_012: MCPServerConfig fromJson // TI_TYPES_012: MCPServerConfig fromJson
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_012_MCPServerConfigFromJson", "[mcp][types]") { TEST_CASE("TI_TYPES_012_MCPServerConfigFromJson", "[mcp][types]") {
json j = { json j = {
{"command", "mcp-server-filesystem"}, {"command", "mcp-server-filesystem"},
{"args", json::array({"--root", "/home"})}, {"args", json::array({"--root", "/home"})},
{"env", {{"DEBUG", "true"}}}, {"env", {{"DEBUG", "true"}}},
{"enabled", true} {"enabled", true}
}; };
auto config = MCPServerConfig::fromJson("filesystem", j); auto config = MCPServerConfig::fromJson("filesystem", j);
REQUIRE(config.name == "filesystem"); REQUIRE(config.name == "filesystem");
REQUIRE(config.command == "mcp-server-filesystem"); REQUIRE(config.command == "mcp-server-filesystem");
REQUIRE(config.args.size() == 2); REQUIRE(config.args.size() == 2);
REQUIRE(config.args[0] == "--root"); REQUIRE(config.args[0] == "--root");
REQUIRE(config.args[1] == "/home"); REQUIRE(config.args[1] == "/home");
REQUIRE(config.env["DEBUG"] == "true"); REQUIRE(config.env["DEBUG"] == "true");
REQUIRE(config.enabled == true); REQUIRE(config.enabled == true);
} }
// ============================================================================ // ============================================================================
// TI_TYPES_013: MCPServerConfig env expansion // TI_TYPES_013: MCPServerConfig env expansion
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_013_MCPServerConfigEnvExpansion", "[mcp][types]") { TEST_CASE("TI_TYPES_013_MCPServerConfigEnvExpansion", "[mcp][types]") {
json j = { json j = {
{"command", "mcp-server"}, {"command", "mcp-server"},
{"env", {{"API_KEY", "${MY_API_KEY}"}}} {"env", {{"API_KEY", "${MY_API_KEY}"}}}
}; };
auto config = MCPServerConfig::fromJson("test", j); auto config = MCPServerConfig::fromJson("test", j);
// Note: Actual env expansion happens in MCPClient, not in fromJson // Note: Actual env expansion happens in MCPClient, not in fromJson
// This test verifies the raw value is stored // This test verifies the raw value is stored
REQUIRE(config.env["API_KEY"] == "${MY_API_KEY}"); REQUIRE(config.env["API_KEY"] == "${MY_API_KEY}");
} }
// ============================================================================ // ============================================================================
// TI_TYPES_014: MCPServerConfig disabled // TI_TYPES_014: MCPServerConfig disabled
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_014_MCPServerConfigDisabled", "[mcp][types]") { TEST_CASE("TI_TYPES_014_MCPServerConfigDisabled", "[mcp][types]") {
json j = { json j = {
{"command", "some-server"}, {"command", "some-server"},
{"enabled", false} {"enabled", false}
}; };
auto config = MCPServerConfig::fromJson("disabled_server", j); auto config = MCPServerConfig::fromJson("disabled_server", j);
REQUIRE(config.enabled == false); REQUIRE(config.enabled == false);
} }
// ============================================================================ // ============================================================================
// TI_TYPES_015: JsonRpcRequest ID increment // TI_TYPES_015: JsonRpcRequest ID increment
// ============================================================================ // ============================================================================
TEST_CASE("TI_TYPES_015_JsonRpcRequestIdIncrement", "[mcp][types]") { TEST_CASE("TI_TYPES_015_JsonRpcRequestIdIncrement", "[mcp][types]") {
JsonRpcRequest req1; JsonRpcRequest req1;
req1.id = 1; req1.id = 1;
req1.method = "test"; req1.method = "test";
JsonRpcRequest req2; JsonRpcRequest req2;
req2.id = 2; req2.id = 2;
req2.method = "test"; req2.method = "test";
// IDs should be different // IDs should be different
REQUIRE(req1.id != req2.id); REQUIRE(req1.id != req2.id);
// Both should serialize correctly // Both should serialize correctly
json j1 = req1.toJson(); json j1 = req1.toJson();
json j2 = req2.toJson(); json j2 = req2.toJson();
REQUIRE(j1["id"] == 1); REQUIRE(j1["id"] == 1);
REQUIRE(j2["id"] == 2); REQUIRE(j2["id"] == 2);
} }

View File

@ -1,445 +1,445 @@
/** /**
* @file StdioTransportTests.cpp * @file StdioTransportTests.cpp
* @brief Integration tests for StdioTransport (20 TI) * @brief Integration tests for StdioTransport (20 TI)
*/ */
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include "shared/mcp/StdioTransport.hpp" #include "shared/mcp/StdioTransport.hpp"
#include "shared/mcp/MCPTypes.hpp" #include "shared/mcp/MCPTypes.hpp"
#include <thread> #include <thread>
#include <chrono> #include <chrono>
using namespace aissia::mcp; using namespace aissia::mcp;
using json = nlohmann::json; using json = nlohmann::json;
// ============================================================================ // ============================================================================
// Helper: Create config for echo server // Helper: Create config for echo server
// ============================================================================ // ============================================================================
MCPServerConfig makeEchoServerConfig() { MCPServerConfig makeEchoServerConfig() {
MCPServerConfig config; MCPServerConfig config;
config.name = "echo"; config.name = "echo";
config.command = "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"; config.command = "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe";
config.args = {"tests/fixtures/echo_server.py"}; config.args = {"tests/fixtures/echo_server.py"};
config.enabled = true; config.enabled = true;
return config; return config;
} }
MCPServerConfig makeMockMCPServerConfig() { MCPServerConfig makeMockMCPServerConfig() {
MCPServerConfig config; MCPServerConfig config;
config.name = "mock_mcp"; config.name = "mock_mcp";
config.command = "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"; config.command = "C:\\Users\\alexi\\AppData\\Local\\Programs\\Python\\Python312\\python.exe";
config.args = {"tests/fixtures/mock_mcp_server.py"}; config.args = {"tests/fixtures/mock_mcp_server.py"};
config.enabled = true; config.enabled = true;
return config; return config;
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_001: Start spawns process // TI_TRANSPORT_001: Start spawns process
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_001_StartSpawnsProcess", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_001_StartSpawnsProcess", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
bool started = transport.start(); bool started = transport.start();
REQUIRE(started == true); REQUIRE(started == true);
REQUIRE(transport.isRunning() == true); REQUIRE(transport.isRunning() == true);
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_002: Start fails with invalid command // TI_TRANSPORT_002: Start fails with invalid command
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_002_StartFailsInvalidCommand", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_002_StartFailsInvalidCommand", "[mcp][transport]") {
MCPServerConfig config; MCPServerConfig config;
config.name = "invalid"; config.name = "invalid";
config.command = "nonexistent_command_xyz"; config.command = "nonexistent_command_xyz";
config.enabled = true; config.enabled = true;
StdioTransport transport(config); StdioTransport transport(config);
bool started = transport.start(); bool started = transport.start();
REQUIRE(started == false); REQUIRE(started == false);
REQUIRE(transport.isRunning() == false); REQUIRE(transport.isRunning() == false);
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_003: Stop kills process // TI_TRANSPORT_003: Stop kills process
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_003_StopKillsProcess", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_003_StopKillsProcess", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
REQUIRE(transport.isRunning() == true); REQUIRE(transport.isRunning() == true);
transport.stop(); transport.stop();
REQUIRE(transport.isRunning() == false); REQUIRE(transport.isRunning() == false);
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_004: IsRunning reflects state // TI_TRANSPORT_004: IsRunning reflects state
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_004_IsRunningReflectsState", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_004_IsRunningReflectsState", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
REQUIRE(transport.isRunning() == false); REQUIRE(transport.isRunning() == false);
transport.start(); transport.start();
REQUIRE(transport.isRunning() == true); REQUIRE(transport.isRunning() == true);
transport.stop(); transport.stop();
REQUIRE(transport.isRunning() == false); REQUIRE(transport.isRunning() == false);
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_005: SendRequest writes to stdin // TI_TRANSPORT_005: SendRequest writes to stdin
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_005_SendRequestWritesToStdin", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_005_SendRequestWritesToStdin", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
JsonRpcRequest request; JsonRpcRequest request;
request.id = 1; request.id = 1;
request.method = "test"; request.method = "test";
request.params = {{"message", "hello"}}; request.params = {{"message", "hello"}};
// Echo server will echo back params as result // Echo server will echo back params as result
auto response = transport.sendRequest(request, 5000); auto response = transport.sendRequest(request, 5000);
// If we got a response, the request was written // If we got a response, the request was written
REQUIRE(response.isError() == false); REQUIRE(response.isError() == false);
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_006: SendRequest reads response // TI_TRANSPORT_006: SendRequest reads response
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_006_SendRequestReadsResponse", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_006_SendRequestReadsResponse", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
JsonRpcRequest request; JsonRpcRequest request;
request.id = 42; request.id = 42;
request.method = "echo"; request.method = "echo";
request.params = {{"value", 123}}; request.params = {{"value", 123}};
auto response = transport.sendRequest(request, 5000); auto response = transport.sendRequest(request, 5000);
REQUIRE(response.isError() == false); REQUIRE(response.isError() == false);
REQUIRE(response.id == 42); REQUIRE(response.id == 42);
REQUIRE(response.result.has_value()); REQUIRE(response.result.has_value());
REQUIRE(response.result.value()["value"] == 123); REQUIRE(response.result.value()["value"] == 123);
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_007: SendRequest timeout // TI_TRANSPORT_007: SendRequest timeout
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_007_SendRequestTimeout", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_007_SendRequestTimeout", "[mcp][transport]") {
// Use cat which doesn't respond to JSON-RPC // Use cat which doesn't respond to JSON-RPC
MCPServerConfig config; MCPServerConfig config;
config.name = "cat"; config.name = "cat";
config.command = "cat"; config.command = "cat";
config.enabled = true; config.enabled = true;
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
JsonRpcRequest request; JsonRpcRequest request;
request.id = 1; request.id = 1;
request.method = "test"; request.method = "test";
// Very short timeout // Very short timeout
auto response = transport.sendRequest(request, 100); auto response = transport.sendRequest(request, 100);
// Should timeout and return error // Should timeout and return error
REQUIRE(response.isError() == true); REQUIRE(response.isError() == true);
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_008: SendRequest ID matching // TI_TRANSPORT_008: SendRequest ID matching
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_008_SendRequestIdMatching", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_008_SendRequestIdMatching", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
// Send request with specific ID // Send request with specific ID
JsonRpcRequest request; JsonRpcRequest request;
request.id = 999; request.id = 999;
request.method = "test"; request.method = "test";
auto response = transport.sendRequest(request, 5000); auto response = transport.sendRequest(request, 5000);
// Response ID should match request ID // Response ID should match request ID
REQUIRE(response.id == 999); REQUIRE(response.id == 999);
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_009: Concurrent requests // TI_TRANSPORT_009: Concurrent requests
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_009_ConcurrentRequests", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_009_ConcurrentRequests", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
std::vector<std::thread> threads; std::vector<std::thread> threads;
std::vector<bool> results(5, false); std::vector<bool> results(5, false);
for (int i = 0; i < 5; i++) { for (int i = 0; i < 5; i++) {
threads.emplace_back([&transport, &results, i]() { threads.emplace_back([&transport, &results, i]() {
JsonRpcRequest request; JsonRpcRequest request;
request.id = 100 + i; request.id = 100 + i;
request.method = "test"; request.method = "test";
request.params = {{"index", i}}; request.params = {{"index", i}};
auto response = transport.sendRequest(request, 5000); auto response = transport.sendRequest(request, 5000);
results[i] = !response.isError() && response.id == 100 + i; results[i] = !response.isError() && response.id == 100 + i;
}); });
} }
for (auto& t : threads) { for (auto& t : threads) {
t.join(); t.join();
} }
// All requests should succeed // All requests should succeed
for (bool result : results) { for (bool result : results) {
REQUIRE(result == true); REQUIRE(result == true);
} }
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_010: SendNotification no response // TI_TRANSPORT_010: SendNotification no response
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_010_SendNotificationNoResponse", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_010_SendNotificationNoResponse", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
// Should not block or throw // Should not block or throw
REQUIRE_NOTHROW(transport.sendNotification("notification/test", {{"data", "value"}})); REQUIRE_NOTHROW(transport.sendNotification("notification/test", {{"data", "value"}}));
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_011: Reader thread starts on start // TI_TRANSPORT_011: Reader thread starts on start
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_011_ReaderThreadStartsOnStart", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_011_ReaderThreadStartsOnStart", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
// If reader thread didn't start, sendRequest would hang // If reader thread didn't start, sendRequest would hang
JsonRpcRequest request; JsonRpcRequest request;
request.id = 1; request.id = 1;
request.method = "test"; request.method = "test";
auto response = transport.sendRequest(request, 1000); auto response = transport.sendRequest(request, 1000);
// Got response means reader thread is working // Got response means reader thread is working
REQUIRE(response.isError() == false); REQUIRE(response.isError() == false);
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_012: Reader thread stops on stop // TI_TRANSPORT_012: Reader thread stops on stop
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_012_ReaderThreadStopsOnStop", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_012_ReaderThreadStopsOnStop", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
transport.stop(); transport.stop();
// Should not hang or crash on destruction // Should not hang or crash on destruction
SUCCEED(); SUCCEED();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_013: JSON parse error handled // TI_TRANSPORT_013: JSON parse error handled
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_013_JsonParseErrorHandled", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_013_JsonParseErrorHandled", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
// Send valid request - server will respond with valid JSON // Send valid request - server will respond with valid JSON
JsonRpcRequest request; JsonRpcRequest request;
request.id = 1; request.id = 1;
request.method = "test"; request.method = "test";
// Should not crash even if server sends invalid JSON // Should not crash even if server sends invalid JSON
REQUIRE_NOTHROW(transport.sendRequest(request, 1000)); REQUIRE_NOTHROW(transport.sendRequest(request, 1000));
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_014: Process crash detected // TI_TRANSPORT_014: Process crash detected
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_014_ProcessCrashDetected", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_014_ProcessCrashDetected", "[mcp][transport]") {
// TODO: Need a server that crashes to test this // TODO: Need a server that crashes to test this
// For now, just verify we can handle stop // For now, just verify we can handle stop
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
transport.stop(); transport.stop();
REQUIRE(transport.isRunning() == false); REQUIRE(transport.isRunning() == false);
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_015: Large message handling // TI_TRANSPORT_015: Large message handling
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_015_LargeMessageHandling", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_015_LargeMessageHandling", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
// Create large params // Create large params
std::string largeString(10000, 'x'); std::string largeString(10000, 'x');
JsonRpcRequest request; JsonRpcRequest request;
request.id = 1; request.id = 1;
request.method = "test"; request.method = "test";
request.params = {{"data", largeString}}; request.params = {{"data", largeString}};
auto response = transport.sendRequest(request, 10000); auto response = transport.sendRequest(request, 10000);
REQUIRE(response.isError() == false); REQUIRE(response.isError() == false);
REQUIRE(response.result.value()["data"] == largeString); REQUIRE(response.result.value()["data"] == largeString);
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_016: Multiline JSON handling // TI_TRANSPORT_016: Multiline JSON handling
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_016_MultilineJsonHandling", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_016_MultilineJsonHandling", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
// JSON with newlines in strings should work // JSON with newlines in strings should work
JsonRpcRequest request; JsonRpcRequest request;
request.id = 1; request.id = 1;
request.method = "test"; request.method = "test";
request.params = {{"text", "line1\nline2\nline3"}}; request.params = {{"text", "line1\nline2\nline3"}};
auto response = transport.sendRequest(request, 5000); auto response = transport.sendRequest(request, 5000);
REQUIRE(response.isError() == false); REQUIRE(response.isError() == false);
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_017: Env variables passed to process // TI_TRANSPORT_017: Env variables passed to process
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_017_EnvVariablesPassedToProcess", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_017_EnvVariablesPassedToProcess", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
config.env["TEST_VAR"] = "test_value"; config.env["TEST_VAR"] = "test_value";
StdioTransport transport(config); StdioTransport transport(config);
bool started = transport.start(); bool started = transport.start();
REQUIRE(started == true); REQUIRE(started == true);
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_018: Args passed to process // TI_TRANSPORT_018: Args passed to process
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_018_ArgsPassedToProcess", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_018_ArgsPassedToProcess", "[mcp][transport]") {
auto config = makeMockMCPServerConfig(); auto config = makeMockMCPServerConfig();
// Args are already set in the helper function // Args are already set in the helper function
StdioTransport transport(config); StdioTransport transport(config);
bool started = transport.start(); bool started = transport.start();
REQUIRE(started == true); REQUIRE(started == true);
transport.stop(); transport.stop();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_019: Destructor cleans up // TI_TRANSPORT_019: Destructor cleans up
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_019_DestructorCleansUp", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_019_DestructorCleansUp", "[mcp][transport]") {
{ {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
transport.start(); transport.start();
// Destructor called here // Destructor called here
} }
// Should not leak resources or hang // Should not leak resources or hang
SUCCEED(); SUCCEED();
} }
// ============================================================================ // ============================================================================
// TI_TRANSPORT_020: Restart after stop // TI_TRANSPORT_020: Restart after stop
// ============================================================================ // ============================================================================
TEST_CASE("TI_TRANSPORT_020_RestartAfterStop", "[mcp][transport]") { TEST_CASE("TI_TRANSPORT_020_RestartAfterStop", "[mcp][transport]") {
auto config = makeEchoServerConfig(); auto config = makeEchoServerConfig();
StdioTransport transport(config); StdioTransport transport(config);
// First start/stop // First start/stop
transport.start(); transport.start();
transport.stop(); transport.stop();
REQUIRE(transport.isRunning() == false); REQUIRE(transport.isRunning() == false);
// Second start // Second start
bool restarted = transport.start(); bool restarted = transport.start();
REQUIRE(restarted == true); REQUIRE(restarted == true);
REQUIRE(transport.isRunning() == true); REQUIRE(transport.isRunning() == true);
// Verify it works // Verify it works
JsonRpcRequest request; JsonRpcRequest request;
request.id = 1; request.id = 1;
request.method = "test"; request.method = "test";
auto response = transport.sendRequest(request, 5000); auto response = transport.sendRequest(request, 5000);
REQUIRE(response.isError() == false); REQUIRE(response.isError() == false);
transport.stop(); transport.stop();
} }

View File

@ -1,88 +1,88 @@
#include "MockIO.hpp" #include "MockIO.hpp"
namespace aissia::tests { namespace aissia::tests {
void MockIO::publish(const std::string& topic, std::unique_ptr<grove::IDataNode> data) { void MockIO::publish(const std::string& topic, std::unique_ptr<grove::IDataNode> data) {
// Convert IDataNode to JSON for easy verification // Convert IDataNode to JSON for easy verification
json jsonData; json jsonData;
if (data) { if (data) {
// Try to extract JSON from JsonDataNode // Try to extract JSON from JsonDataNode
auto* jsonNode = dynamic_cast<grove::JsonDataNode*>(data.get()); auto* jsonNode = dynamic_cast<grove::JsonDataNode*>(data.get());
if (jsonNode) { if (jsonNode) {
jsonData = jsonNode->getJsonData(); jsonData = jsonNode->getJsonData();
} else { } else {
// Fallback: create basic JSON from IDataNode interface // Fallback: create basic JSON from IDataNode interface
jsonData = json::object(); jsonData = json::object();
} }
} }
m_publishedMessages.emplace_back(topic, jsonData); m_publishedMessages.emplace_back(topic, jsonData);
} }
grove::Message MockIO::pullMessage() { grove::Message MockIO::pullMessage() {
if (m_incomingMessages.empty()) { if (m_incomingMessages.empty()) {
throw std::runtime_error("No messages available"); throw std::runtime_error("No messages available");
} }
grove::Message msg = std::move(m_incomingMessages.front()); grove::Message msg = std::move(m_incomingMessages.front());
m_incomingMessages.pop(); m_incomingMessages.pop();
return msg; return msg;
} }
void MockIO::injectMessage(const std::string& topic, const json& data) { void MockIO::injectMessage(const std::string& topic, const json& data) {
grove::Message message; grove::Message message;
message.topic = topic; message.topic = topic;
message.data = std::make_unique<grove::JsonDataNode>("data", data); message.data = std::make_unique<grove::JsonDataNode>("data", data);
message.timestamp = 0; message.timestamp = 0;
m_incomingMessages.push(std::move(message)); m_incomingMessages.push(std::move(message));
} }
void MockIO::injectMessages(const std::vector<std::pair<std::string, json>>& messages) { void MockIO::injectMessages(const std::vector<std::pair<std::string, json>>& messages) {
for (const auto& [topic, data] : messages) { for (const auto& [topic, data] : messages) {
injectMessage(topic, data); injectMessage(topic, data);
} }
} }
bool MockIO::wasPublished(const std::string& topic) const { bool MockIO::wasPublished(const std::string& topic) const {
return std::any_of(m_publishedMessages.begin(), m_publishedMessages.end(), return std::any_of(m_publishedMessages.begin(), m_publishedMessages.end(),
[&topic](const auto& msg) { return msg.first == topic; }); [&topic](const auto& msg) { return msg.first == topic; });
} }
json MockIO::getLastPublished(const std::string& topic) const { json MockIO::getLastPublished(const std::string& topic) const {
for (auto it = m_publishedMessages.rbegin(); it != m_publishedMessages.rend(); ++it) { for (auto it = m_publishedMessages.rbegin(); it != m_publishedMessages.rend(); ++it) {
if (it->first == topic) { if (it->first == topic) {
return it->second; return it->second;
} }
} }
return json::object(); return json::object();
} }
std::vector<json> MockIO::getAllPublished(const std::string& topic) const { std::vector<json> MockIO::getAllPublished(const std::string& topic) const {
std::vector<json> result; std::vector<json> result;
for (const auto& [t, data] : m_publishedMessages) { for (const auto& [t, data] : m_publishedMessages) {
if (t == topic) { if (t == topic) {
result.push_back(data); result.push_back(data);
} }
} }
return result; return result;
} }
size_t MockIO::countPublished(const std::string& topic) const { size_t MockIO::countPublished(const std::string& topic) const {
return std::count_if(m_publishedMessages.begin(), m_publishedMessages.end(), return std::count_if(m_publishedMessages.begin(), m_publishedMessages.end(),
[&topic](const auto& msg) { return msg.first == topic; }); [&topic](const auto& msg) { return msg.first == topic; });
} }
void MockIO::clear() { void MockIO::clear() {
m_publishedMessages.clear(); m_publishedMessages.clear();
while (!m_incomingMessages.empty()) { while (!m_incomingMessages.empty()) {
m_incomingMessages.pop(); m_incomingMessages.pop();
} }
m_subscriptions.clear(); m_subscriptions.clear();
} }
void MockIO::clearPublished() { void MockIO::clearPublished() {
m_publishedMessages.clear(); m_publishedMessages.clear();
} }
} // namespace aissia::tests } // namespace aissia::tests

View File

@ -1,129 +1,129 @@
#pragma once #pragma once
#include <grove/IIO.h> #include <grove/IIO.h>
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <string> #include <string>
#include <vector> #include <vector>
#include <queue> #include <queue>
#include <map> #include <map>
#include <algorithm> #include <algorithm>
namespace aissia::tests { namespace aissia::tests {
using json = nlohmann::json; using json = nlohmann::json;
/** /**
* @brief Mock implementation of grove::IIO for testing * @brief Mock implementation of grove::IIO for testing
* *
* Captures published messages and allows injecting incoming messages. * Captures published messages and allows injecting incoming messages.
*/ */
class MockIO : public grove::IIO { class MockIO : public grove::IIO {
public: public:
// ======================================================================== // ========================================================================
// IIO Interface Implementation // IIO Interface Implementation
// ======================================================================== // ========================================================================
void publish(const std::string& topic, std::unique_ptr<grove::IDataNode> data) override; void publish(const std::string& topic, std::unique_ptr<grove::IDataNode> data) override;
void subscribe(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override { void subscribe(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override {
// Mock: just record subscription // Mock: just record subscription
m_subscriptions.push_back(topicPattern); m_subscriptions.push_back(topicPattern);
} }
void subscribeLowFreq(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override { void subscribeLowFreq(const std::string& topicPattern, const grove::SubscriptionConfig& config = {}) override {
// Mock: same as subscribe // Mock: same as subscribe
m_subscriptions.push_back(topicPattern); m_subscriptions.push_back(topicPattern);
} }
int hasMessages() const override { int hasMessages() const override {
return static_cast<int>(m_incomingMessages.size()); return static_cast<int>(m_incomingMessages.size());
} }
grove::Message pullMessage() override; grove::Message pullMessage() override;
grove::IOHealth getHealth() const override { grove::IOHealth getHealth() const override {
return grove::IOHealth{ return grove::IOHealth{
.queueSize = static_cast<int>(m_incomingMessages.size()), .queueSize = static_cast<int>(m_incomingMessages.size()),
.maxQueueSize = 1000, .maxQueueSize = 1000,
.dropping = false, .dropping = false,
.averageProcessingRate = 100.0f, .averageProcessingRate = 100.0f,
.droppedMessageCount = 0 .droppedMessageCount = 0
}; };
} }
grove::IOType getType() const override { grove::IOType getType() const override {
return grove::IOType::INTRA; return grove::IOType::INTRA;
} }
// ======================================================================== // ========================================================================
// Test Helpers - Message Injection // Test Helpers - Message Injection
// ======================================================================== // ========================================================================
/** /**
* @brief Inject a message to be received by the module under test * @brief Inject a message to be received by the module under test
*/ */
void injectMessage(const std::string& topic, const json& data); void injectMessage(const std::string& topic, const json& data);
/** /**
* @brief Inject multiple messages at once * @brief Inject multiple messages at once
*/ */
void injectMessages(const std::vector<std::pair<std::string, json>>& messages); void injectMessages(const std::vector<std::pair<std::string, json>>& messages);
// ======================================================================== // ========================================================================
// Test Helpers - Verification // Test Helpers - Verification
// ======================================================================== // ========================================================================
/** /**
* @brief Check if a message was published to a specific topic * @brief Check if a message was published to a specific topic
*/ */
bool wasPublished(const std::string& topic) const; bool wasPublished(const std::string& topic) const;
/** /**
* @brief Get the last message published to a topic * @brief Get the last message published to a topic
*/ */
json getLastPublished(const std::string& topic) const; json getLastPublished(const std::string& topic) const;
/** /**
* @brief Get all messages published to a topic * @brief Get all messages published to a topic
*/ */
std::vector<json> getAllPublished(const std::string& topic) const; std::vector<json> getAllPublished(const std::string& topic) const;
/** /**
* @brief Count messages published to a topic * @brief Count messages published to a topic
*/ */
size_t countPublished(const std::string& topic) const; size_t countPublished(const std::string& topic) const;
/** /**
* @brief Get all published messages (topic -> data pairs) * @brief Get all published messages (topic -> data pairs)
*/ */
const std::vector<std::pair<std::string, json>>& getPublishedMessages() const { const std::vector<std::pair<std::string, json>>& getPublishedMessages() const {
return m_publishedMessages; return m_publishedMessages;
} }
/** /**
* @brief Clear all captured and pending messages * @brief Clear all captured and pending messages
*/ */
void clear(); void clear();
/** /**
* @brief Clear only published messages (keep incoming queue) * @brief Clear only published messages (keep incoming queue)
*/ */
void clearPublished(); void clearPublished();
// ======================================================================== // ========================================================================
// Test State // Test State
// ======================================================================== // ========================================================================
/// All messages published by the module under test /// All messages published by the module under test
std::vector<std::pair<std::string, json>> m_publishedMessages; std::vector<std::pair<std::string, json>> m_publishedMessages;
/// Messages waiting to be received by the module /// Messages waiting to be received by the module
std::queue<grove::Message> m_incomingMessages; std::queue<grove::Message> m_incomingMessages;
/// Subscribed topic patterns (for verification) /// Subscribed topic patterns (for verification)
std::vector<std::string> m_subscriptions; std::vector<std::string> m_subscriptions;
}; };
} // namespace aissia::tests } // namespace aissia::tests

View File

@ -1,192 +1,192 @@
#pragma once #pragma once
#include "shared/mcp/MCPTransport.hpp" #include "shared/mcp/MCPTransport.hpp"
#include "shared/mcp/MCPTypes.hpp" #include "shared/mcp/MCPTypes.hpp"
#include <queue> #include <queue>
#include <vector> #include <vector>
#include <functional> #include <functional>
namespace aissia::tests { namespace aissia::tests {
using namespace aissia::mcp; using namespace aissia::mcp;
/** /**
* @brief Mock implementation of IMCPTransport for testing MCPClient * @brief Mock implementation of IMCPTransport for testing MCPClient
*/ */
class MockTransport : public IMCPTransport { class MockTransport : public IMCPTransport {
public: public:
// ======================================================================== // ========================================================================
// IMCPTransport Interface // IMCPTransport Interface
// ======================================================================== // ========================================================================
bool start() override { bool start() override {
if (m_startShouldFail) { if (m_startShouldFail) {
return false; return false;
} }
m_running = true; m_running = true;
return true; return true;
} }
void stop() override { void stop() override {
m_running = false; m_running = false;
} }
bool isRunning() const override { bool isRunning() const override {
return m_running; return m_running;
} }
JsonRpcResponse sendRequest(const JsonRpcRequest& request, int timeoutMs = 30000) override { JsonRpcResponse sendRequest(const JsonRpcRequest& request, int timeoutMs = 30000) override {
m_sentRequests.push_back(request); m_sentRequests.push_back(request);
// If we have a custom handler, use it // If we have a custom handler, use it
if (m_requestHandler) { if (m_requestHandler) {
return m_requestHandler(request); return m_requestHandler(request);
} }
// Otherwise, use prepared responses // Otherwise, use prepared responses
if (!m_preparedResponses.empty()) { if (!m_preparedResponses.empty()) {
auto response = m_preparedResponses.front(); auto response = m_preparedResponses.front();
m_preparedResponses.pop(); m_preparedResponses.pop();
response.id = request.id; // Match the request ID response.id = request.id; // Match the request ID
return response; return response;
} }
// Default: return error // Default: return error
JsonRpcResponse errorResponse; JsonRpcResponse errorResponse;
errorResponse.id = request.id; errorResponse.id = request.id;
errorResponse.error = json{{"code", -32603}, {"message", "No prepared response"}}; errorResponse.error = json{{"code", -32603}, {"message", "No prepared response"}};
return errorResponse; return errorResponse;
} }
void sendNotification(const std::string& method, const json& params) override { void sendNotification(const std::string& method, const json& params) override {
m_sentNotifications.emplace_back(method, params); m_sentNotifications.emplace_back(method, params);
} }
// ======================================================================== // ========================================================================
// Test Configuration // Test Configuration
// ======================================================================== // ========================================================================
/** /**
* @brief Make start() fail * @brief Make start() fail
*/ */
void setStartShouldFail(bool fail) { void setStartShouldFail(bool fail) {
m_startShouldFail = fail; m_startShouldFail = fail;
} }
/** /**
* @brief Add a response to be returned on next sendRequest * @brief Add a response to be returned on next sendRequest
*/ */
void prepareResponse(const JsonRpcResponse& response) { void prepareResponse(const JsonRpcResponse& response) {
m_preparedResponses.push(response); m_preparedResponses.push(response);
} }
/** /**
* @brief Prepare a successful response with result * @brief Prepare a successful response with result
*/ */
void prepareSuccessResponse(const json& result) { void prepareSuccessResponse(const json& result) {
JsonRpcResponse response; JsonRpcResponse response;
response.result = result; response.result = result;
m_preparedResponses.push(response); m_preparedResponses.push(response);
} }
/** /**
* @brief Prepare an error response * @brief Prepare an error response
*/ */
void prepareErrorResponse(int code, const std::string& message) { void prepareErrorResponse(int code, const std::string& message) {
JsonRpcResponse response; JsonRpcResponse response;
response.error = json{{"code", code}, {"message", message}}; response.error = json{{"code", code}, {"message", message}};
m_preparedResponses.push(response); m_preparedResponses.push(response);
} }
/** /**
* @brief Set a custom handler for all requests * @brief Set a custom handler for all requests
*/ */
void setRequestHandler(std::function<JsonRpcResponse(const JsonRpcRequest&)> handler) { void setRequestHandler(std::function<JsonRpcResponse(const JsonRpcRequest&)> handler) {
m_requestHandler = std::move(handler); m_requestHandler = std::move(handler);
} }
/** /**
* @brief Simulate MCP server with initialize and tools/list * @brief Simulate MCP server with initialize and tools/list
*/ */
void setupAsMCPServer(const std::string& serverName, const std::vector<MCPTool>& tools) { void setupAsMCPServer(const std::string& serverName, const std::vector<MCPTool>& tools) {
m_requestHandler = [serverName, tools](const JsonRpcRequest& req) -> JsonRpcResponse { m_requestHandler = [serverName, tools](const JsonRpcRequest& req) -> JsonRpcResponse {
JsonRpcResponse resp; JsonRpcResponse resp;
resp.id = req.id; resp.id = req.id;
if (req.method == "initialize") { if (req.method == "initialize") {
resp.result = json{ resp.result = json{
{"protocolVersion", "2024-11-05"}, {"protocolVersion", "2024-11-05"},
{"capabilities", {{"tools", json::object()}}}, {"capabilities", {{"tools", json::object()}}},
{"serverInfo", {{"name", serverName}, {"version", "1.0.0"}}} {"serverInfo", {{"name", serverName}, {"version", "1.0.0"}}}
}; };
} else if (req.method == "tools/list") { } else if (req.method == "tools/list") {
json toolsJson = json::array(); json toolsJson = json::array();
for (const auto& tool : tools) { for (const auto& tool : tools) {
toolsJson.push_back(tool.toJson()); toolsJson.push_back(tool.toJson());
} }
resp.result = json{{"tools", toolsJson}}; resp.result = json{{"tools", toolsJson}};
} else if (req.method == "tools/call") { } else if (req.method == "tools/call") {
resp.result = json{ resp.result = json{
{"content", json::array({{{"type", "text"}, {"text", "Tool executed"}}})} {"content", json::array({{{"type", "text"}, {"text", "Tool executed"}}})}
}; };
} else { } else {
resp.error = json{{"code", -32601}, {"message", "Method not found"}}; resp.error = json{{"code", -32601}, {"message", "Method not found"}};
} }
return resp; return resp;
}; };
} }
// ======================================================================== // ========================================================================
// Test Verification // Test Verification
// ======================================================================== // ========================================================================
/** /**
* @brief Get all sent requests * @brief Get all sent requests
*/ */
const std::vector<JsonRpcRequest>& getSentRequests() const { const std::vector<JsonRpcRequest>& getSentRequests() const {
return m_sentRequests; return m_sentRequests;
} }
/** /**
* @brief Check if a method was called * @brief Check if a method was called
*/ */
bool wasMethodCalled(const std::string& method) const { bool wasMethodCalled(const std::string& method) const {
return std::any_of(m_sentRequests.begin(), m_sentRequests.end(), return std::any_of(m_sentRequests.begin(), m_sentRequests.end(),
[&method](const auto& req) { return req.method == method; }); [&method](const auto& req) { return req.method == method; });
} }
/** /**
* @brief Get count of calls to a method * @brief Get count of calls to a method
*/ */
size_t countMethodCalls(const std::string& method) const { size_t countMethodCalls(const std::string& method) const {
return std::count_if(m_sentRequests.begin(), m_sentRequests.end(), return std::count_if(m_sentRequests.begin(), m_sentRequests.end(),
[&method](const auto& req) { return req.method == method; }); [&method](const auto& req) { return req.method == method; });
} }
/** /**
* @brief Clear all state * @brief Clear all state
*/ */
void clear() { void clear() {
m_sentRequests.clear(); m_sentRequests.clear();
m_sentNotifications.clear(); m_sentNotifications.clear();
while (!m_preparedResponses.empty()) { while (!m_preparedResponses.empty()) {
m_preparedResponses.pop(); m_preparedResponses.pop();
} }
m_requestHandler = nullptr; m_requestHandler = nullptr;
} }
// ======================================================================== // ========================================================================
// Test State // Test State
// ======================================================================== // ========================================================================
bool m_running = false; bool m_running = false;
bool m_startShouldFail = false; bool m_startShouldFail = false;
std::vector<JsonRpcRequest> m_sentRequests; std::vector<JsonRpcRequest> m_sentRequests;
std::vector<std::pair<std::string, json>> m_sentNotifications; std::vector<std::pair<std::string, json>> m_sentNotifications;
std::queue<JsonRpcResponse> m_preparedResponses; std::queue<JsonRpcResponse> m_preparedResponses;
std::function<JsonRpcResponse(const JsonRpcRequest&)> m_requestHandler; std::function<JsonRpcResponse(const JsonRpcRequest&)> m_requestHandler;
}; };
} // namespace aissia::tests } // namespace aissia::tests

View File

@ -1,293 +1,293 @@
/** /**
* @file AIModuleTests.cpp * @file AIModuleTests.cpp
* @brief Integration tests for AIModule (10 TI) * @brief Integration tests for AIModule (10 TI)
*/ */
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include "mocks/MockIO.hpp" #include "mocks/MockIO.hpp"
#include "utils/TimeSimulator.hpp" #include "utils/TimeSimulator.hpp"
#include "utils/TestHelpers.hpp" #include "utils/TestHelpers.hpp"
#include "modules/AIModule.h" #include "modules/AIModule.h"
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
using namespace aissia; using namespace aissia;
using namespace aissia::tests; using namespace aissia::tests;
// ============================================================================ // ============================================================================
// Test Fixture // Test Fixture
// ============================================================================ // ============================================================================
class AITestFixture { class AITestFixture {
public: public:
MockIO io; MockIO io;
TimeSimulator time; TimeSimulator time;
AIModule module; AIModule module;
void configure(const json& config = json::object()) { void configure(const json& config = json::object()) {
json fullConfig = { json fullConfig = {
{"system_prompt", "Tu es un assistant personnel intelligent."}, {"system_prompt", "Tu es un assistant personnel intelligent."},
{"max_iterations", 10} {"max_iterations", 10}
}; };
fullConfig.merge_patch(config); fullConfig.merge_patch(config);
grove::JsonDataNode configNode("config", fullConfig); grove::JsonDataNode configNode("config", fullConfig);
module.setConfiguration(configNode, &io, nullptr); module.setConfiguration(configNode, &io, nullptr);
} }
void process() { void process() {
grove::JsonDataNode input("input", time.createInput()); grove::JsonDataNode input("input", time.createInput());
module.process(input); module.process(input);
} }
}; };
// ============================================================================ // ============================================================================
// TI_AI_001: Query Sends LLM Request // TI_AI_001: Query Sends LLM Request
// ============================================================================ // ============================================================================
TEST_CASE("TI_AI_001_QuerySendsLLMRequest", "[ai][integration]") { TEST_CASE("TI_AI_001_QuerySendsLLMRequest", "[ai][integration]") {
AITestFixture f; AITestFixture f;
f.configure(); f.configure();
// Send query // Send query
f.io.injectMessage("ai:query", {{"query", "Quelle heure est-il?"}}); f.io.injectMessage("ai:query", {{"query", "Quelle heure est-il?"}});
f.process(); f.process();
// Verify LLM request published // Verify LLM request published
REQUIRE(f.io.wasPublished("llm:request")); REQUIRE(f.io.wasPublished("llm:request"));
auto msg = f.io.getLastPublished("llm:request"); auto msg = f.io.getLastPublished("llm:request");
REQUIRE(msg["query"] == "Quelle heure est-il?"); REQUIRE(msg["query"] == "Quelle heure est-il?");
} }
// ============================================================================ // ============================================================================
// TI_AI_002: Voice Transcription Triggers Query // TI_AI_002: Voice Transcription Triggers Query
// ============================================================================ // ============================================================================
TEST_CASE("TI_AI_002_VoiceTranscriptionTriggersQuery", "[ai][integration]") { TEST_CASE("TI_AI_002_VoiceTranscriptionTriggersQuery", "[ai][integration]") {
AITestFixture f; AITestFixture f;
f.configure(); f.configure();
// Send voice transcription // Send voice transcription
f.io.injectMessage("voice:transcription", { f.io.injectMessage("voice:transcription", {
{"text", "Aide-moi avec mon code"}, {"text", "Aide-moi avec mon code"},
{"confidence", 0.95} {"confidence", 0.95}
}); });
f.process(); f.process();
// Verify LLM request // Verify LLM request
REQUIRE(f.io.wasPublished("llm:request")); REQUIRE(f.io.wasPublished("llm:request"));
auto msg = f.io.getLastPublished("llm:request"); auto msg = f.io.getLastPublished("llm:request");
REQUIRE(msg["query"] == "Aide-moi avec mon code"); REQUIRE(msg["query"] == "Aide-moi avec mon code");
} }
// ============================================================================ // ============================================================================
// TI_AI_003: LLM Response Handled // TI_AI_003: LLM Response Handled
// ============================================================================ // ============================================================================
TEST_CASE("TI_AI_003_LLMResponseHandled", "[ai][integration]") { TEST_CASE("TI_AI_003_LLMResponseHandled", "[ai][integration]") {
AITestFixture f; AITestFixture f;
f.configure(); f.configure();
// Send query to set awaiting state // Send query to set awaiting state
f.io.injectMessage("ai:query", {{"query", "Test"}}); f.io.injectMessage("ai:query", {{"query", "Test"}});
f.process(); f.process();
REQUIRE(f.module.isIdle() == false); REQUIRE(f.module.isIdle() == false);
// Receive response // Receive response
f.io.injectMessage("llm:response", { f.io.injectMessage("llm:response", {
{"text", "Voici la reponse"}, {"text", "Voici la reponse"},
{"tokens", 100}, {"tokens", 100},
{"conversationId", "default"} {"conversationId", "default"}
}); });
f.process(); f.process();
// Verify no longer awaiting // Verify no longer awaiting
REQUIRE(f.module.isIdle() == true); REQUIRE(f.module.isIdle() == true);
} }
// ============================================================================ // ============================================================================
// TI_AI_004: LLM Error Handled // TI_AI_004: LLM Error Handled
// ============================================================================ // ============================================================================
TEST_CASE("TI_AI_004_LLMErrorHandled", "[ai][integration]") { TEST_CASE("TI_AI_004_LLMErrorHandled", "[ai][integration]") {
AITestFixture f; AITestFixture f;
f.configure(); f.configure();
// Send query // Send query
f.io.injectMessage("ai:query", {{"query", "Test"}}); f.io.injectMessage("ai:query", {{"query", "Test"}});
f.process(); f.process();
REQUIRE(f.module.isIdle() == false); REQUIRE(f.module.isIdle() == false);
// Receive error // Receive error
f.io.injectMessage("llm:error", { f.io.injectMessage("llm:error", {
{"message", "API rate limit exceeded"}, {"message", "API rate limit exceeded"},
{"conversationId", "default"} {"conversationId", "default"}
}); });
f.process(); f.process();
// Should no longer be awaiting // Should no longer be awaiting
REQUIRE(f.module.isIdle() == true); REQUIRE(f.module.isIdle() == true);
} }
// ============================================================================ // ============================================================================
// TI_AI_005: Hyperfocus Alert Generates Suggestion // TI_AI_005: Hyperfocus Alert Generates Suggestion
// ============================================================================ // ============================================================================
TEST_CASE("TI_AI_005_HyperfocusAlertGeneratesSuggestion", "[ai][integration]") { TEST_CASE("TI_AI_005_HyperfocusAlertGeneratesSuggestion", "[ai][integration]") {
AITestFixture f; AITestFixture f;
f.configure(); f.configure();
// Receive hyperfocus alert // Receive hyperfocus alert
f.io.injectMessage("scheduler:hyperfocus_alert", { f.io.injectMessage("scheduler:hyperfocus_alert", {
{"sessionMinutes", 130}, {"sessionMinutes", 130},
{"task", "coding"} {"task", "coding"}
}); });
f.process(); f.process();
// Verify LLM request published // Verify LLM request published
REQUIRE(f.io.wasPublished("llm:request")); REQUIRE(f.io.wasPublished("llm:request"));
auto req = f.io.getLastPublished("llm:request"); auto req = f.io.getLastPublished("llm:request");
std::string convId = req["conversationId"]; std::string convId = req["conversationId"];
// Simulate LLM response // Simulate LLM response
f.io.injectMessage("llm:response", { f.io.injectMessage("llm:response", {
{"text", "Time to take a break!"}, {"text", "Time to take a break!"},
{"conversationId", convId} {"conversationId", convId}
}); });
f.process(); f.process();
// Verify suggestion published // Verify suggestion published
REQUIRE(f.io.wasPublished("ai:suggestion")); REQUIRE(f.io.wasPublished("ai:suggestion"));
auto msg = f.io.getLastPublished("ai:suggestion"); auto msg = f.io.getLastPublished("ai:suggestion");
REQUIRE(msg.contains("message")); REQUIRE(msg.contains("message"));
} }
// ============================================================================ // ============================================================================
// TI_AI_006: Break Reminder Generates Suggestion // TI_AI_006: Break Reminder Generates Suggestion
// ============================================================================ // ============================================================================
TEST_CASE("TI_AI_006_BreakReminderGeneratesSuggestion", "[ai][integration]") { TEST_CASE("TI_AI_006_BreakReminderGeneratesSuggestion", "[ai][integration]") {
AITestFixture f; AITestFixture f;
f.configure(); f.configure();
// Receive break reminder // Receive break reminder
f.io.injectMessage("scheduler:break_reminder", { f.io.injectMessage("scheduler:break_reminder", {
{"workMinutes", 45} {"workMinutes", 45}
}); });
f.process(); f.process();
// Verify LLM request published // Verify LLM request published
REQUIRE(f.io.wasPublished("llm:request")); REQUIRE(f.io.wasPublished("llm:request"));
auto req = f.io.getLastPublished("llm:request"); auto req = f.io.getLastPublished("llm:request");
std::string convId = req["conversationId"]; std::string convId = req["conversationId"];
// Simulate LLM response // Simulate LLM response
f.io.injectMessage("llm:response", { f.io.injectMessage("llm:response", {
{"text", "Take a short break now!"}, {"text", "Take a short break now!"},
{"conversationId", convId} {"conversationId", convId}
}); });
f.process(); f.process();
// Verify suggestion // Verify suggestion
REQUIRE(f.io.wasPublished("ai:suggestion")); REQUIRE(f.io.wasPublished("ai:suggestion"));
} }
// ============================================================================ // ============================================================================
// TI_AI_007: System Prompt In Request // TI_AI_007: System Prompt In Request
// ============================================================================ // ============================================================================
TEST_CASE("TI_AI_007_SystemPromptInRequest", "[ai][integration]") { TEST_CASE("TI_AI_007_SystemPromptInRequest", "[ai][integration]") {
AITestFixture f; AITestFixture f;
f.configure({{"system_prompt", "Custom prompt here"}}); f.configure({{"system_prompt", "Custom prompt here"}});
f.io.injectMessage("ai:query", {{"query", "Test"}}); f.io.injectMessage("ai:query", {{"query", "Test"}});
f.process(); f.process();
REQUIRE(f.io.wasPublished("llm:request")); REQUIRE(f.io.wasPublished("llm:request"));
auto msg = f.io.getLastPublished("llm:request"); auto msg = f.io.getLastPublished("llm:request");
REQUIRE(msg["systemPrompt"] == "Custom prompt here"); REQUIRE(msg["systemPrompt"] == "Custom prompt here");
} }
// ============================================================================ // ============================================================================
// TI_AI_008: Conversation ID Tracking // TI_AI_008: Conversation ID Tracking
// ============================================================================ // ============================================================================
TEST_CASE("TI_AI_008_ConversationIdTracking", "[ai][integration]") { TEST_CASE("TI_AI_008_ConversationIdTracking", "[ai][integration]") {
AITestFixture f; AITestFixture f;
f.configure(); f.configure();
// First query // First query
f.io.injectMessage("ai:query", {{"query", "Question 1"}}); f.io.injectMessage("ai:query", {{"query", "Question 1"}});
f.process(); f.process();
auto msg1 = f.io.getLastPublished("llm:request"); auto msg1 = f.io.getLastPublished("llm:request");
std::string convId = msg1["conversationId"]; std::string convId = msg1["conversationId"];
REQUIRE(!convId.empty()); REQUIRE(!convId.empty());
// Simulate response // Simulate response
f.io.injectMessage("llm:response", {{"text", "Response"}, {"conversationId", convId}}); f.io.injectMessage("llm:response", {{"text", "Response"}, {"conversationId", convId}});
f.process(); f.process();
f.io.clearPublished(); f.io.clearPublished();
// Second query should use same conversation // Second query should use same conversation
f.io.injectMessage("ai:query", {{"query", "Question 2"}}); f.io.injectMessage("ai:query", {{"query", "Question 2"}});
f.process(); f.process();
auto msg2 = f.io.getLastPublished("llm:request"); auto msg2 = f.io.getLastPublished("llm:request");
REQUIRE(msg2["conversationId"] == convId); REQUIRE(msg2["conversationId"] == convId);
} }
// ============================================================================ // ============================================================================
// TI_AI_009: Token Counting Accumulates // TI_AI_009: Token Counting Accumulates
// ============================================================================ // ============================================================================
TEST_CASE("TI_AI_009_TokenCountingAccumulates", "[ai][integration]") { TEST_CASE("TI_AI_009_TokenCountingAccumulates", "[ai][integration]") {
AITestFixture f; AITestFixture f;
f.configure(); f.configure();
// Query 1 // Query 1
f.io.injectMessage("ai:query", {{"query", "Q1"}}); f.io.injectMessage("ai:query", {{"query", "Q1"}});
f.process(); f.process();
f.io.injectMessage("llm:response", {{"text", "R1"}, {"tokens", 50}}); f.io.injectMessage("llm:response", {{"text", "R1"}, {"tokens", 50}});
f.process(); f.process();
// Query 2 // Query 2
f.io.injectMessage("ai:query", {{"query", "Q2"}}); f.io.injectMessage("ai:query", {{"query", "Q2"}});
f.process(); f.process();
f.io.injectMessage("llm:response", {{"text", "R2"}, {"tokens", 75}}); f.io.injectMessage("llm:response", {{"text", "R2"}, {"tokens", 75}});
f.process(); f.process();
// Verify total // Verify total
auto state = f.module.getState(); auto state = f.module.getState();
// TODO: Verify totalTokens == 125 // TODO: Verify totalTokens == 125
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_AI_010: State Serialization // TI_AI_010: State Serialization
// ============================================================================ // ============================================================================
TEST_CASE("TI_AI_010_StateSerialization", "[ai][integration]") { TEST_CASE("TI_AI_010_StateSerialization", "[ai][integration]") {
AITestFixture f; AITestFixture f;
f.configure(); f.configure();
// Build state // Build state
f.io.injectMessage("ai:query", {{"query", "Test"}}); f.io.injectMessage("ai:query", {{"query", "Test"}});
f.process(); f.process();
f.io.injectMessage("llm:response", {{"text", "Response"}, {"tokens", 100}}); f.io.injectMessage("llm:response", {{"text", "Response"}, {"tokens", 100}});
f.process(); f.process();
// Get state // Get state
auto state = f.module.getState(); auto state = f.module.getState();
REQUIRE(state != nullptr); REQUIRE(state != nullptr);
// Restore // Restore
AIModule module2; AIModule module2;
grove::JsonDataNode configNode2("config", json::object()); grove::JsonDataNode configNode2("config", json::object());
module2.setConfiguration(configNode2, &f.io, nullptr); module2.setConfiguration(configNode2, &f.io, nullptr);
module2.setState(*state); module2.setState(*state);
auto state2 = module2.getState(); auto state2 = module2.getState();
REQUIRE(state2 != nullptr); REQUIRE(state2 != nullptr);
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }

View File

@ -1,285 +1,285 @@
/** /**
* @file MonitoringModuleTests.cpp * @file MonitoringModuleTests.cpp
* @brief Integration tests for MonitoringModule (10 TI) * @brief Integration tests for MonitoringModule (10 TI)
*/ */
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include "mocks/MockIO.hpp" #include "mocks/MockIO.hpp"
#include "utils/TimeSimulator.hpp" #include "utils/TimeSimulator.hpp"
#include "utils/TestHelpers.hpp" #include "utils/TestHelpers.hpp"
#include "modules/MonitoringModule.h" #include "modules/MonitoringModule.h"
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
using namespace aissia; using namespace aissia;
using namespace aissia::tests; using namespace aissia::tests;
// ============================================================================ // ============================================================================
// Test Fixture // Test Fixture
// ============================================================================ // ============================================================================
class MonitoringTestFixture { class MonitoringTestFixture {
public: public:
MockIO io; MockIO io;
TimeSimulator time; TimeSimulator time;
MonitoringModule module; MonitoringModule module;
void configure(const json& config = json::object()) { void configure(const json& config = json::object()) {
json fullConfig = { json fullConfig = {
{"enabled", true}, {"enabled", true},
{"productive_apps", json::array({"Code", "CLion", "Visual Studio"})}, {"productive_apps", json::array({"Code", "CLion", "Visual Studio"})},
{"distracting_apps", json::array({"Discord", "Steam", "YouTube"})} {"distracting_apps", json::array({"Discord", "Steam", "YouTube"})}
}; };
fullConfig.merge_patch(config); fullConfig.merge_patch(config);
grove::JsonDataNode configNode("config", fullConfig); grove::JsonDataNode configNode("config", fullConfig);
module.setConfiguration(configNode, &io, nullptr); module.setConfiguration(configNode, &io, nullptr);
} }
void process() { void process() {
grove::JsonDataNode input("input", time.createInput()); grove::JsonDataNode input("input", time.createInput());
module.process(input); module.process(input);
} }
}; };
// ============================================================================ // ============================================================================
// TI_MONITOR_001: App Changed // TI_MONITOR_001: App Changed
// ============================================================================ // ============================================================================
TEST_CASE("TI_MONITOR_001_AppChanged", "[monitoring][integration]") { TEST_CASE("TI_MONITOR_001_AppChanged", "[monitoring][integration]") {
MonitoringTestFixture f; MonitoringTestFixture f;
f.configure(); f.configure();
// Inject window change // Inject window change
f.io.injectMessage("platform:window_changed", { f.io.injectMessage("platform:window_changed", {
{"oldApp", ""}, {"oldApp", ""},
{"newApp", "Code"}, {"newApp", "Code"},
{"duration", 0} {"duration", 0}
}); });
f.process(); f.process();
// Verify app_changed published // Verify app_changed published
REQUIRE(f.io.wasPublished("monitoring:app_changed")); REQUIRE(f.io.wasPublished("monitoring:app_changed"));
auto msg = f.io.getLastPublished("monitoring:app_changed"); auto msg = f.io.getLastPublished("monitoring:app_changed");
REQUIRE(msg["appName"] == "Code"); REQUIRE(msg["appName"] == "Code");
} }
// ============================================================================ // ============================================================================
// TI_MONITOR_002: Productive App Classification // TI_MONITOR_002: Productive App Classification
// ============================================================================ // ============================================================================
TEST_CASE("TI_MONITOR_002_ProductiveAppClassification", "[monitoring][integration]") { TEST_CASE("TI_MONITOR_002_ProductiveAppClassification", "[monitoring][integration]") {
MonitoringTestFixture f; MonitoringTestFixture f;
f.configure(); f.configure();
f.io.injectMessage("platform:window_changed", { f.io.injectMessage("platform:window_changed", {
{"oldApp", ""}, {"oldApp", ""},
{"newApp", "Code"}, {"newApp", "Code"},
{"duration", 0} {"duration", 0}
}); });
f.process(); f.process();
REQUIRE(f.io.wasPublished("monitoring:app_changed")); REQUIRE(f.io.wasPublished("monitoring:app_changed"));
auto msg = f.io.getLastPublished("monitoring:app_changed"); auto msg = f.io.getLastPublished("monitoring:app_changed");
REQUIRE(msg["classification"] == "productive"); REQUIRE(msg["classification"] == "productive");
} }
// ============================================================================ // ============================================================================
// TI_MONITOR_003: Distracting App Classification // TI_MONITOR_003: Distracting App Classification
// ============================================================================ // ============================================================================
TEST_CASE("TI_MONITOR_003_DistractingAppClassification", "[monitoring][integration]") { TEST_CASE("TI_MONITOR_003_DistractingAppClassification", "[monitoring][integration]") {
MonitoringTestFixture f; MonitoringTestFixture f;
f.configure(); f.configure();
f.io.injectMessage("platform:window_changed", { f.io.injectMessage("platform:window_changed", {
{"oldApp", ""}, {"oldApp", ""},
{"newApp", "Discord"}, {"newApp", "Discord"},
{"duration", 0} {"duration", 0}
}); });
f.process(); f.process();
REQUIRE(f.io.wasPublished("monitoring:app_changed")); REQUIRE(f.io.wasPublished("monitoring:app_changed"));
auto msg = f.io.getLastPublished("monitoring:app_changed"); auto msg = f.io.getLastPublished("monitoring:app_changed");
REQUIRE(msg["classification"] == "distracting"); REQUIRE(msg["classification"] == "distracting");
} }
// ============================================================================ // ============================================================================
// TI_MONITOR_004: Neutral App Classification // TI_MONITOR_004: Neutral App Classification
// ============================================================================ // ============================================================================
TEST_CASE("TI_MONITOR_004_NeutralAppClassification", "[monitoring][integration]") { TEST_CASE("TI_MONITOR_004_NeutralAppClassification", "[monitoring][integration]") {
MonitoringTestFixture f; MonitoringTestFixture f;
f.configure(); f.configure();
f.io.injectMessage("platform:window_changed", { f.io.injectMessage("platform:window_changed", {
{"oldApp", ""}, {"oldApp", ""},
{"newApp", "Notepad"}, {"newApp", "Notepad"},
{"duration", 0} {"duration", 0}
}); });
f.process(); f.process();
REQUIRE(f.io.wasPublished("monitoring:app_changed")); REQUIRE(f.io.wasPublished("monitoring:app_changed"));
auto msg = f.io.getLastPublished("monitoring:app_changed"); auto msg = f.io.getLastPublished("monitoring:app_changed");
REQUIRE(msg["classification"] == "neutral"); REQUIRE(msg["classification"] == "neutral");
} }
// ============================================================================ // ============================================================================
// TI_MONITOR_005: Duration Tracking // TI_MONITOR_005: Duration Tracking
// ============================================================================ // ============================================================================
TEST_CASE("TI_MONITOR_005_DurationTracking", "[monitoring][integration]") { TEST_CASE("TI_MONITOR_005_DurationTracking", "[monitoring][integration]") {
MonitoringTestFixture f; MonitoringTestFixture f;
f.configure(); f.configure();
// Start with Code // Start with Code
f.io.injectMessage("platform:window_changed", { f.io.injectMessage("platform:window_changed", {
{"oldApp", ""}, {"oldApp", ""},
{"newApp", "Code"}, {"newApp", "Code"},
{"duration", 0} {"duration", 0}
}); });
f.process(); f.process();
f.io.clearPublished(); f.io.clearPublished();
// Switch after 60 seconds // Switch after 60 seconds
f.io.injectMessage("platform:window_changed", { f.io.injectMessage("platform:window_changed", {
{"oldApp", "Code"}, {"oldApp", "Code"},
{"newApp", "Discord"}, {"newApp", "Discord"},
{"duration", 60} {"duration", 60}
}); });
f.process(); f.process();
// Verify duration tracked // Verify duration tracked
auto state = f.module.getState(); auto state = f.module.getState();
// TODO: Verify appDurations["Code"] == 60 // TODO: Verify appDurations["Code"] == 60
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_MONITOR_006: Idle Detected Pauses Tracking // TI_MONITOR_006: Idle Detected Pauses Tracking
// ============================================================================ // ============================================================================
TEST_CASE("TI_MONITOR_006_IdleDetectedPausesTracking", "[monitoring][integration]") { TEST_CASE("TI_MONITOR_006_IdleDetectedPausesTracking", "[monitoring][integration]") {
MonitoringTestFixture f; MonitoringTestFixture f;
f.configure(); f.configure();
// Start tracking // Start tracking
f.io.injectMessage("platform:window_changed", { f.io.injectMessage("platform:window_changed", {
{"oldApp", ""}, {"oldApp", ""},
{"newApp", "Code"}, {"newApp", "Code"},
{"duration", 0} {"duration", 0}
}); });
f.process(); f.process();
// Go idle // Go idle
f.io.injectMessage("platform:idle_detected", {{"idleSeconds", 300}}); f.io.injectMessage("platform:idle_detected", {{"idleSeconds", 300}});
f.process(); f.process();
// Verify idle state // Verify idle state
auto state = f.module.getState(); auto state = f.module.getState();
// TODO: Verify isIdle == true // TODO: Verify isIdle == true
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_MONITOR_007: Activity Resumed Resumes Tracking // TI_MONITOR_007: Activity Resumed Resumes Tracking
// ============================================================================ // ============================================================================
TEST_CASE("TI_MONITOR_007_ActivityResumedResumesTracking", "[monitoring][integration]") { TEST_CASE("TI_MONITOR_007_ActivityResumedResumesTracking", "[monitoring][integration]") {
MonitoringTestFixture f; MonitoringTestFixture f;
f.configure(); f.configure();
// Setup idle state // Setup idle state
f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}});
f.process(); f.process();
f.io.injectMessage("platform:idle_detected", {}); f.io.injectMessage("platform:idle_detected", {});
f.process(); f.process();
// Resume // Resume
f.io.injectMessage("platform:activity_resumed", {}); f.io.injectMessage("platform:activity_resumed", {});
f.process(); f.process();
// Verify not idle // Verify not idle
auto state = f.module.getState(); auto state = f.module.getState();
// TODO: Verify isIdle == false // TODO: Verify isIdle == false
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_MONITOR_008: Productivity Stats // TI_MONITOR_008: Productivity Stats
// ============================================================================ // ============================================================================
TEST_CASE("TI_MONITOR_008_ProductivityStats", "[monitoring][integration]") { TEST_CASE("TI_MONITOR_008_ProductivityStats", "[monitoring][integration]") {
MonitoringTestFixture f; MonitoringTestFixture f;
f.configure(); f.configure();
// Use productive app for 60s // Use productive app for 60s
f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}});
f.process(); f.process();
f.io.injectMessage("platform:window_changed", {{"oldApp", "Code"}, {"newApp", "Discord"}, {"duration", 60}}); f.io.injectMessage("platform:window_changed", {{"oldApp", "Code"}, {"newApp", "Discord"}, {"duration", 60}});
f.process(); f.process();
// Use distracting app for 30s // Use distracting app for 30s
f.io.injectMessage("platform:window_changed", {{"oldApp", "Discord"}, {"newApp", "Code"}, {"duration", 30}}); f.io.injectMessage("platform:window_changed", {{"oldApp", "Discord"}, {"newApp", "Code"}, {"duration", 30}});
f.process(); f.process();
// Verify stats // Verify stats
auto state = f.module.getState(); auto state = f.module.getState();
// TODO: Verify totalProductiveSeconds == 60, totalDistractingSeconds == 30 // TODO: Verify totalProductiveSeconds == 60, totalDistractingSeconds == 30
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_MONITOR_009: Tool Query Get Current App // TI_MONITOR_009: Tool Query Get Current App
// ============================================================================ // ============================================================================
TEST_CASE("TI_MONITOR_009_ToolQueryGetCurrentApp", "[monitoring][integration]") { TEST_CASE("TI_MONITOR_009_ToolQueryGetCurrentApp", "[monitoring][integration]") {
MonitoringTestFixture f; MonitoringTestFixture f;
f.configure(); f.configure();
// Set current app // Set current app
f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}});
f.process(); f.process();
f.io.clearPublished(); f.io.clearPublished();
// Query // Query
f.io.injectMessage("monitoring:query", { f.io.injectMessage("monitoring:query", {
{"action", "get_current_app"}, {"action", "get_current_app"},
{"correlation_id", "test-456"} {"correlation_id", "test-456"}
}); });
f.process(); f.process();
// Verify response // Verify response
REQUIRE(f.io.wasPublished("monitoring:response")); REQUIRE(f.io.wasPublished("monitoring:response"));
auto resp = f.io.getLastPublished("monitoring:response"); auto resp = f.io.getLastPublished("monitoring:response");
REQUIRE(resp["correlation_id"] == "test-456"); REQUIRE(resp["correlation_id"] == "test-456");
} }
// ============================================================================ // ============================================================================
// TI_MONITOR_010: State Serialization // TI_MONITOR_010: State Serialization
// ============================================================================ // ============================================================================
TEST_CASE("TI_MONITOR_010_StateSerialization", "[monitoring][integration]") { TEST_CASE("TI_MONITOR_010_StateSerialization", "[monitoring][integration]") {
MonitoringTestFixture f; MonitoringTestFixture f;
f.configure(); f.configure();
// Build up some state // Build up some state
f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}}); f.io.injectMessage("platform:window_changed", {{"oldApp", ""}, {"newApp", "Code"}, {"duration", 0}});
f.process(); f.process();
f.io.injectMessage("platform:window_changed", {{"oldApp", "Code"}, {"newApp", "Discord"}, {"duration", 120}}); f.io.injectMessage("platform:window_changed", {{"oldApp", "Code"}, {"newApp", "Discord"}, {"duration", 120}});
f.process(); f.process();
// Get state // Get state
auto state = f.module.getState(); auto state = f.module.getState();
REQUIRE(state != nullptr); REQUIRE(state != nullptr);
// Restore to new module // Restore to new module
MonitoringModule module2; MonitoringModule module2;
grove::JsonDataNode configNode2("config", json::object()); grove::JsonDataNode configNode2("config", json::object());
module2.setConfiguration(configNode2, &f.io, nullptr); module2.setConfiguration(configNode2, &f.io, nullptr);
module2.setState(*state); module2.setState(*state);
auto state2 = module2.getState(); auto state2 = module2.getState();
REQUIRE(state2 != nullptr); REQUIRE(state2 != nullptr);
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }

View File

@ -1,303 +1,303 @@
/** /**
* @file NotificationModuleTests.cpp * @file NotificationModuleTests.cpp
* @brief Integration tests for NotificationModule (10 TI) * @brief Integration tests for NotificationModule (10 TI)
*/ */
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include "mocks/MockIO.hpp" #include "mocks/MockIO.hpp"
#include "utils/TimeSimulator.hpp" #include "utils/TimeSimulator.hpp"
#include "utils/TestHelpers.hpp" #include "utils/TestHelpers.hpp"
#include "modules/NotificationModule.h" #include "modules/NotificationModule.h"
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
using namespace aissia; using namespace aissia;
using namespace aissia::tests; using namespace aissia::tests;
// ============================================================================ // ============================================================================
// Test Fixture // Test Fixture
// ============================================================================ // ============================================================================
class NotificationTestFixture { class NotificationTestFixture {
public: public:
MockIO io; MockIO io;
TimeSimulator time; TimeSimulator time;
NotificationModule module; NotificationModule module;
void configure(const json& config = json::object()) { void configure(const json& config = json::object()) {
json fullConfig = { json fullConfig = {
{"language", "fr"}, {"language", "fr"},
{"silentMode", false}, {"silentMode", false},
{"ttsEnabled", false}, {"ttsEnabled", false},
{"maxQueueSize", 50} {"maxQueueSize", 50}
}; };
fullConfig.merge_patch(config); fullConfig.merge_patch(config);
grove::JsonDataNode configNode("config", fullConfig); grove::JsonDataNode configNode("config", fullConfig);
module.setConfiguration(configNode, &io, nullptr); module.setConfiguration(configNode, &io, nullptr);
} }
void process() { void process() {
grove::JsonDataNode input("input", time.createInput()); grove::JsonDataNode input("input", time.createInput());
module.process(input); module.process(input);
} }
int getPendingCount() { int getPendingCount() {
auto state = module.getState(); auto state = module.getState();
return state ? state->getInt("pendingCount", -1) : -1; return state ? state->getInt("pendingCount", -1) : -1;
} }
int getNotificationCount() { int getNotificationCount() {
auto state = module.getState(); auto state = module.getState();
return state ? state->getInt("notificationCount", -1) : -1; return state ? state->getInt("notificationCount", -1) : -1;
} }
int getUrgentCount() { int getUrgentCount() {
auto state = module.getState(); auto state = module.getState();
return state ? state->getInt("urgentCount", -1) : -1; return state ? state->getInt("urgentCount", -1) : -1;
} }
}; };
// ============================================================================ // ============================================================================
// TI_NOTIF_001: Queue Notification // TI_NOTIF_001: Queue Notification
// ============================================================================ // ============================================================================
TEST_CASE("TI_NOTIF_001_QueueNotification", "[notification][integration]") { TEST_CASE("TI_NOTIF_001_QueueNotification", "[notification][integration]") {
NotificationTestFixture f; NotificationTestFixture f;
f.configure(); f.configure();
// Add notification // Add notification
f.module.notify("Test Title", "Test Message", NotificationModule::Priority::NORMAL); f.module.notify("Test Title", "Test Message", NotificationModule::Priority::NORMAL);
// Verify queue has 1 item (before processing) // Verify queue has 1 item (before processing)
REQUIRE(f.getPendingCount() == 1); REQUIRE(f.getPendingCount() == 1);
// Verify notification count incremented // Verify notification count incremented
REQUIRE(f.getNotificationCount() == 1); REQUIRE(f.getNotificationCount() == 1);
} }
// ============================================================================ // ============================================================================
// TI_NOTIF_002: Process Queue (max 3 per frame) // TI_NOTIF_002: Process Queue (max 3 per frame)
// ============================================================================ // ============================================================================
TEST_CASE("TI_NOTIF_002_ProcessQueue", "[notification][integration]") { TEST_CASE("TI_NOTIF_002_ProcessQueue", "[notification][integration]") {
NotificationTestFixture f; NotificationTestFixture f;
f.configure(); f.configure();
// Add 5 notifications // Add 5 notifications
for (int i = 0; i < 5; i++) { for (int i = 0; i < 5; i++) {
f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL); f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL);
} }
// Verify 5 pending before process // Verify 5 pending before process
REQUIRE(f.getPendingCount() == 5); REQUIRE(f.getPendingCount() == 5);
// Process one frame (should handle max 3) // Process one frame (should handle max 3)
f.process(); f.process();
// Verify 2 remaining in queue // Verify 2 remaining in queue
REQUIRE(f.getPendingCount() == 2); REQUIRE(f.getPendingCount() == 2);
} }
// ============================================================================ // ============================================================================
// TI_NOTIF_003: Priority Ordering // TI_NOTIF_003: Priority Ordering
// NOTE: Current implementation uses FIFO queue without priority sorting. // NOTE: Current implementation uses FIFO queue without priority sorting.
// This test verifies that URGENT notifications can still be added // This test verifies that URGENT notifications can still be added
// alongside other priorities. True priority ordering would require // alongside other priorities. True priority ordering would require
// a priority queue implementation. // a priority queue implementation.
// ============================================================================ // ============================================================================
TEST_CASE("TI_NOTIF_003_PriorityOrdering", "[notification][integration]") { TEST_CASE("TI_NOTIF_003_PriorityOrdering", "[notification][integration]") {
NotificationTestFixture f; NotificationTestFixture f;
f.configure(); f.configure();
// Add notifications in reverse priority order // Add notifications in reverse priority order
f.module.notify("Low", "Low priority", NotificationModule::Priority::LOW); f.module.notify("Low", "Low priority", NotificationModule::Priority::LOW);
f.module.notify("Urgent", "Urgent priority", NotificationModule::Priority::URGENT); f.module.notify("Urgent", "Urgent priority", NotificationModule::Priority::URGENT);
f.module.notify("Normal", "Normal priority", NotificationModule::Priority::NORMAL); f.module.notify("Normal", "Normal priority", NotificationModule::Priority::NORMAL);
// Verify all 3 are queued // Verify all 3 are queued
REQUIRE(f.getPendingCount() == 3); REQUIRE(f.getPendingCount() == 3);
// Verify urgent count is tracked // Verify urgent count is tracked
REQUIRE(f.getUrgentCount() == 1); REQUIRE(f.getUrgentCount() == 1);
// Process - verify all are processed // Process - verify all are processed
f.process(); f.process();
REQUIRE(f.getPendingCount() == 0); REQUIRE(f.getPendingCount() == 0);
} }
// ============================================================================ // ============================================================================
// TI_NOTIF_004: Silent Mode Blocks Non-Urgent // TI_NOTIF_004: Silent Mode Blocks Non-Urgent
// ============================================================================ // ============================================================================
TEST_CASE("TI_NOTIF_004_SilentModeBlocksNonUrgent", "[notification][integration]") { TEST_CASE("TI_NOTIF_004_SilentModeBlocksNonUrgent", "[notification][integration]") {
NotificationTestFixture f; NotificationTestFixture f;
f.configure({{"silentMode", true}}); f.configure({{"silentMode", true}});
// Add non-urgent notifications // Add non-urgent notifications
f.module.notify("Low", "Should be blocked", NotificationModule::Priority::LOW); f.module.notify("Low", "Should be blocked", NotificationModule::Priority::LOW);
f.module.notify("Normal", "Should be blocked", NotificationModule::Priority::NORMAL); f.module.notify("Normal", "Should be blocked", NotificationModule::Priority::NORMAL);
f.module.notify("High", "Should be blocked", NotificationModule::Priority::HIGH); f.module.notify("High", "Should be blocked", NotificationModule::Priority::HIGH);
// Verify all were blocked (queue empty) // Verify all were blocked (queue empty)
REQUIRE(f.getPendingCount() == 0); REQUIRE(f.getPendingCount() == 0);
// Verify notification count was NOT incremented for blocked notifications // Verify notification count was NOT incremented for blocked notifications
// Note: Current implementation increments count before checking silentMode // Note: Current implementation increments count before checking silentMode
// So count will be 0 (notify returns early before incrementing) // So count will be 0 (notify returns early before incrementing)
REQUIRE(f.getNotificationCount() == 0); REQUIRE(f.getNotificationCount() == 0);
} }
// ============================================================================ // ============================================================================
// TI_NOTIF_005: Silent Mode Allows Urgent // TI_NOTIF_005: Silent Mode Allows Urgent
// ============================================================================ // ============================================================================
TEST_CASE("TI_NOTIF_005_SilentModeAllowsUrgent", "[notification][integration]") { TEST_CASE("TI_NOTIF_005_SilentModeAllowsUrgent", "[notification][integration]") {
NotificationTestFixture f; NotificationTestFixture f;
f.configure({{"silentMode", true}}); f.configure({{"silentMode", true}});
// Add urgent notification // Add urgent notification
f.module.notify("Urgent", "Should pass", NotificationModule::Priority::URGENT); f.module.notify("Urgent", "Should pass", NotificationModule::Priority::URGENT);
// Verify URGENT notification was queued // Verify URGENT notification was queued
REQUIRE(f.getPendingCount() == 1); REQUIRE(f.getPendingCount() == 1);
// Verify counts // Verify counts
REQUIRE(f.getNotificationCount() == 1); REQUIRE(f.getNotificationCount() == 1);
REQUIRE(f.getUrgentCount() == 1); REQUIRE(f.getUrgentCount() == 1);
} }
// ============================================================================ // ============================================================================
// TI_NOTIF_006: Max Queue Size // TI_NOTIF_006: Max Queue Size
// ============================================================================ // ============================================================================
TEST_CASE("TI_NOTIF_006_MaxQueueSize", "[notification][integration]") { TEST_CASE("TI_NOTIF_006_MaxQueueSize", "[notification][integration]") {
NotificationTestFixture f; NotificationTestFixture f;
f.configure({{"maxQueueSize", 5}}); f.configure({{"maxQueueSize", 5}});
// Add more than max (10 notifications) // Add more than max (10 notifications)
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL); f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL);
} }
// Verify queue is capped at maxQueueSize // Verify queue is capped at maxQueueSize
REQUIRE(f.getPendingCount() <= 5); REQUIRE(f.getPendingCount() <= 5);
// Notification count should still reflect all attempts // Notification count should still reflect all attempts
REQUIRE(f.getNotificationCount() == 10); REQUIRE(f.getNotificationCount() == 10);
} }
// ============================================================================ // ============================================================================
// TI_NOTIF_007: Language Config // TI_NOTIF_007: Language Config
// ============================================================================ // ============================================================================
TEST_CASE("TI_NOTIF_007_LanguageConfig", "[notification][integration]") { TEST_CASE("TI_NOTIF_007_LanguageConfig", "[notification][integration]") {
NotificationTestFixture f; NotificationTestFixture f;
f.configure({{"language", "en"}}); f.configure({{"language", "en"}});
// Verify module accepted configuration (no crash) // Verify module accepted configuration (no crash)
// The language is stored internally and used for notification display // The language is stored internally and used for notification display
// We can verify via getHealthStatus which doesn't expose language directly // We can verify via getHealthStatus which doesn't expose language directly
auto health = f.module.getHealthStatus(); auto health = f.module.getHealthStatus();
REQUIRE(health != nullptr); REQUIRE(health != nullptr);
REQUIRE(health->getString("status", "") == "running"); REQUIRE(health->getString("status", "") == "running");
} }
// ============================================================================ // ============================================================================
// TI_NOTIF_008: Notification Count Tracking // TI_NOTIF_008: Notification Count Tracking
// ============================================================================ // ============================================================================
TEST_CASE("TI_NOTIF_008_NotificationCountTracking", "[notification][integration]") { TEST_CASE("TI_NOTIF_008_NotificationCountTracking", "[notification][integration]") {
NotificationTestFixture f; NotificationTestFixture f;
f.configure(); f.configure();
// Add various notifications // Add various notifications
f.module.notify("Normal1", "msg", NotificationModule::Priority::NORMAL); f.module.notify("Normal1", "msg", NotificationModule::Priority::NORMAL);
f.module.notify("Urgent1", "msg", NotificationModule::Priority::URGENT); f.module.notify("Urgent1", "msg", NotificationModule::Priority::URGENT);
f.module.notify("Urgent2", "msg", NotificationModule::Priority::URGENT); f.module.notify("Urgent2", "msg", NotificationModule::Priority::URGENT);
f.module.notify("Low1", "msg", NotificationModule::Priority::LOW); f.module.notify("Low1", "msg", NotificationModule::Priority::LOW);
// Verify counts // Verify counts
REQUIRE(f.getNotificationCount() == 4); REQUIRE(f.getNotificationCount() == 4);
REQUIRE(f.getUrgentCount() == 2); REQUIRE(f.getUrgentCount() == 2);
REQUIRE(f.getPendingCount() == 4); REQUIRE(f.getPendingCount() == 4);
// Process all // Process all
f.process(); // processes 3 f.process(); // processes 3
f.process(); // processes 1 f.process(); // processes 1
// Verify queue empty but counts preserved // Verify queue empty but counts preserved
REQUIRE(f.getPendingCount() == 0); REQUIRE(f.getPendingCount() == 0);
REQUIRE(f.getNotificationCount() == 4); REQUIRE(f.getNotificationCount() == 4);
REQUIRE(f.getUrgentCount() == 2); REQUIRE(f.getUrgentCount() == 2);
} }
// ============================================================================ // ============================================================================
// TI_NOTIF_009: State Serialization // TI_NOTIF_009: State Serialization
// ============================================================================ // ============================================================================
TEST_CASE("TI_NOTIF_009_StateSerialization", "[notification][integration]") { TEST_CASE("TI_NOTIF_009_StateSerialization", "[notification][integration]") {
NotificationTestFixture f; NotificationTestFixture f;
f.configure(); f.configure();
// Create some state // Create some state
f.module.notify("Test1", "msg", NotificationModule::Priority::NORMAL); f.module.notify("Test1", "msg", NotificationModule::Priority::NORMAL);
f.module.notify("Test2", "msg", NotificationModule::Priority::URGENT); f.module.notify("Test2", "msg", NotificationModule::Priority::URGENT);
f.process(); // Process some f.process(); // Process some
// Get state // Get state
auto state = f.module.getState(); auto state = f.module.getState();
REQUIRE(state != nullptr); REQUIRE(state != nullptr);
// Verify state contains expected fields // Verify state contains expected fields
REQUIRE(state->getInt("notificationCount", -1) == 2); REQUIRE(state->getInt("notificationCount", -1) == 2);
REQUIRE(state->getInt("urgentCount", -1) == 1); REQUIRE(state->getInt("urgentCount", -1) == 1);
// Create new module and restore // Create new module and restore
NotificationModule module2; NotificationModule module2;
MockIO io2; MockIO io2;
grove::JsonDataNode configNode("config", json::object()); grove::JsonDataNode configNode("config", json::object());
module2.setConfiguration(configNode, &io2, nullptr); module2.setConfiguration(configNode, &io2, nullptr);
module2.setState(*state); module2.setState(*state);
// Verify counters were restored // Verify counters were restored
auto state2 = module2.getState(); auto state2 = module2.getState();
REQUIRE(state2 != nullptr); REQUIRE(state2 != nullptr);
REQUIRE(state2->getInt("notificationCount", -1) == 2); REQUIRE(state2->getInt("notificationCount", -1) == 2);
REQUIRE(state2->getInt("urgentCount", -1) == 1); REQUIRE(state2->getInt("urgentCount", -1) == 1);
// Note: pending queue is NOT restored (documented behavior) // Note: pending queue is NOT restored (documented behavior)
REQUIRE(state2->getInt("pendingCount", -1) == 0); REQUIRE(state2->getInt("pendingCount", -1) == 0);
} }
// ============================================================================ // ============================================================================
// TI_NOTIF_010: Multiple Frame Processing // TI_NOTIF_010: Multiple Frame Processing
// ============================================================================ // ============================================================================
TEST_CASE("TI_NOTIF_010_MultipleFrameProcessing", "[notification][integration]") { TEST_CASE("TI_NOTIF_010_MultipleFrameProcessing", "[notification][integration]") {
NotificationTestFixture f; NotificationTestFixture f;
f.configure(); f.configure();
// Add 7 notifications (needs 3 frames to process at 3/frame) // Add 7 notifications (needs 3 frames to process at 3/frame)
for (int i = 0; i < 7; i++) { for (int i = 0; i < 7; i++) {
f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL); f.module.notify("Title", "Message " + std::to_string(i), NotificationModule::Priority::NORMAL);
} }
// Verify initial count // Verify initial count
REQUIRE(f.getPendingCount() == 7); REQUIRE(f.getPendingCount() == 7);
// Frame 1: 3 processed, 4 remaining // Frame 1: 3 processed, 4 remaining
f.process(); f.process();
REQUIRE(f.getPendingCount() == 4); REQUIRE(f.getPendingCount() == 4);
// Frame 2: 3 processed, 1 remaining // Frame 2: 3 processed, 1 remaining
f.process(); f.process();
REQUIRE(f.getPendingCount() == 1); REQUIRE(f.getPendingCount() == 1);
// Frame 3: 1 processed, 0 remaining // Frame 3: 1 processed, 0 remaining
f.process(); f.process();
REQUIRE(f.getPendingCount() == 0); REQUIRE(f.getPendingCount() == 0);
// Total notification count should be unchanged // Total notification count should be unchanged
REQUIRE(f.getNotificationCount() == 7); REQUIRE(f.getNotificationCount() == 7);
} }

View File

@ -1,315 +1,315 @@
/** /**
* @file SchedulerModuleTests.cpp * @file SchedulerModuleTests.cpp
* @brief Integration tests for SchedulerModule (10 TI) * @brief Integration tests for SchedulerModule (10 TI)
*/ */
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include "mocks/MockIO.hpp" #include "mocks/MockIO.hpp"
#include "utils/TimeSimulator.hpp" #include "utils/TimeSimulator.hpp"
#include "utils/TestHelpers.hpp" #include "utils/TestHelpers.hpp"
#include "modules/SchedulerModule.h" #include "modules/SchedulerModule.h"
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
using namespace aissia; using namespace aissia;
using namespace aissia::tests; using namespace aissia::tests;
// ============================================================================ // ============================================================================
// Test Fixture // Test Fixture
// ============================================================================ // ============================================================================
class SchedulerTestFixture { class SchedulerTestFixture {
public: public:
MockIO io; MockIO io;
TimeSimulator time; TimeSimulator time;
SchedulerModule module; SchedulerModule module;
void configure(const json& config = json::object()) { void configure(const json& config = json::object()) {
json fullConfig = { json fullConfig = {
{"hyperfocusThresholdMinutes", 120}, {"hyperfocusThresholdMinutes", 120},
{"breakReminderIntervalMinutes", 45}, {"breakReminderIntervalMinutes", 45},
{"breakDurationMinutes", 10} {"breakDurationMinutes", 10}
}; };
fullConfig.merge_patch(config); fullConfig.merge_patch(config);
grove::JsonDataNode configNode("config", fullConfig); grove::JsonDataNode configNode("config", fullConfig);
module.setConfiguration(configNode, &io, nullptr); module.setConfiguration(configNode, &io, nullptr);
} }
void process() { void process() {
grove::JsonDataNode input("input", time.createInput()); grove::JsonDataNode input("input", time.createInput());
module.process(input); module.process(input);
} }
void processWithTime(float gameTime) { void processWithTime(float gameTime) {
time.setTime(gameTime); time.setTime(gameTime);
grove::JsonDataNode input("input", time.createInput(0.1f)); grove::JsonDataNode input("input", time.createInput(0.1f));
module.process(input); module.process(input);
} }
}; };
// ============================================================================ // ============================================================================
// TI_SCHEDULER_001: Start Task // TI_SCHEDULER_001: Start Task
// ============================================================================ // ============================================================================
TEST_CASE("TI_SCHEDULER_001_StartTask", "[scheduler][integration]") { TEST_CASE("TI_SCHEDULER_001_StartTask", "[scheduler][integration]") {
SchedulerTestFixture f; SchedulerTestFixture f;
f.configure(); f.configure();
// Inject task switch message // Inject task switch message
f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}});
// Process // Process
f.process(); f.process();
// Verify task_started was published // Verify task_started was published
REQUIRE(f.io.wasPublished("scheduler:task_started")); REQUIRE(f.io.wasPublished("scheduler:task_started"));
auto msg = f.io.getLastPublished("scheduler:task_started"); auto msg = f.io.getLastPublished("scheduler:task_started");
REQUIRE(msg["taskId"] == "task-1"); REQUIRE(msg["taskId"] == "task-1");
REQUIRE(msg.contains("taskName")); REQUIRE(msg.contains("taskName"));
} }
// ============================================================================ // ============================================================================
// TI_SCHEDULER_002: Complete Task // TI_SCHEDULER_002: Complete Task
// ============================================================================ // ============================================================================
TEST_CASE("TI_SCHEDULER_002_CompleteTask", "[scheduler][integration]") { TEST_CASE("TI_SCHEDULER_002_CompleteTask", "[scheduler][integration]") {
SchedulerTestFixture f; SchedulerTestFixture f;
f.configure(); f.configure();
// Start a task at time 0 // Start a task at time 0
f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}});
f.processWithTime(0.0f); f.processWithTime(0.0f);
f.io.clearPublished(); f.io.clearPublished();
// Advance time 30 minutes (1800 seconds) // Advance time 30 minutes (1800 seconds)
f.time.setTime(1800.0f); f.time.setTime(1800.0f);
// Switch to another task (completes current task implicitly) // Switch to another task (completes current task implicitly)
f.io.injectMessage("user:task_switch", {{"taskId", "task-2"}}); f.io.injectMessage("user:task_switch", {{"taskId", "task-2"}});
f.process(); f.process();
// Verify task_completed was published with duration // Verify task_completed was published with duration
REQUIRE(f.io.wasPublished("scheduler:task_completed")); REQUIRE(f.io.wasPublished("scheduler:task_completed"));
auto msg = f.io.getLastPublished("scheduler:task_completed"); auto msg = f.io.getLastPublished("scheduler:task_completed");
REQUIRE(msg["taskId"] == "task-1"); REQUIRE(msg["taskId"] == "task-1");
REQUIRE(msg.contains("duration")); REQUIRE(msg.contains("duration"));
// Duration should be around 30 minutes // Duration should be around 30 minutes
int duration = msg["duration"].get<int>(); int duration = msg["duration"].get<int>();
REQUIRE(duration >= 29); REQUIRE(duration >= 29);
REQUIRE(duration <= 31); REQUIRE(duration <= 31);
} }
// ============================================================================ // ============================================================================
// TI_SCHEDULER_003: Hyperfocus Detection // TI_SCHEDULER_003: Hyperfocus Detection
// ============================================================================ // ============================================================================
TEST_CASE("TI_SCHEDULER_003_HyperfocusDetection", "[scheduler][integration]") { TEST_CASE("TI_SCHEDULER_003_HyperfocusDetection", "[scheduler][integration]") {
SchedulerTestFixture f; SchedulerTestFixture f;
f.configure({{"hyperfocusThresholdMinutes", 120}}); f.configure({{"hyperfocusThresholdMinutes", 120}});
// Start a task at time 0 // Start a task at time 0
f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}});
f.processWithTime(0.0f); f.processWithTime(0.0f);
f.io.clearPublished(); f.io.clearPublished();
// Advance time past threshold (121 minutes = 7260 seconds) // Advance time past threshold (121 minutes = 7260 seconds)
f.processWithTime(7260.0f); f.processWithTime(7260.0f);
// Verify hyperfocus alert // Verify hyperfocus alert
REQUIRE(f.io.wasPublished("scheduler:hyperfocus_alert")); REQUIRE(f.io.wasPublished("scheduler:hyperfocus_alert"));
auto msg = f.io.getLastPublished("scheduler:hyperfocus_alert"); auto msg = f.io.getLastPublished("scheduler:hyperfocus_alert");
REQUIRE(msg["type"] == "hyperfocus"); REQUIRE(msg["type"] == "hyperfocus");
REQUIRE(msg["task"] == "task-1"); REQUIRE(msg["task"] == "task-1");
REQUIRE(msg["duration_minutes"].get<int>() >= 120); REQUIRE(msg["duration_minutes"].get<int>() >= 120);
} }
// ============================================================================ // ============================================================================
// TI_SCHEDULER_004: Hyperfocus Alert Only Once // TI_SCHEDULER_004: Hyperfocus Alert Only Once
// ============================================================================ // ============================================================================
TEST_CASE("TI_SCHEDULER_004_HyperfocusAlertOnce", "[scheduler][integration]") { TEST_CASE("TI_SCHEDULER_004_HyperfocusAlertOnce", "[scheduler][integration]") {
SchedulerTestFixture f; SchedulerTestFixture f;
f.configure({{"hyperfocusThresholdMinutes", 120}}); f.configure({{"hyperfocusThresholdMinutes", 120}});
// Start task // Start task
f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}});
f.processWithTime(0.0f); f.processWithTime(0.0f);
// Trigger hyperfocus (121 min) // Trigger hyperfocus (121 min)
f.processWithTime(7260.0f); f.processWithTime(7260.0f);
// Count first alert // Count first alert
size_t alertCount = f.io.countPublished("scheduler:hyperfocus_alert"); size_t alertCount = f.io.countPublished("scheduler:hyperfocus_alert");
REQUIRE(alertCount == 1); REQUIRE(alertCount == 1);
// Continue processing (130 min, 140 min) // Continue processing (130 min, 140 min)
f.processWithTime(7800.0f); f.processWithTime(7800.0f);
f.processWithTime(8400.0f); f.processWithTime(8400.0f);
// Should still be only 1 alert // Should still be only 1 alert
REQUIRE(f.io.countPublished("scheduler:hyperfocus_alert") == 1); REQUIRE(f.io.countPublished("scheduler:hyperfocus_alert") == 1);
} }
// ============================================================================ // ============================================================================
// TI_SCHEDULER_005: Break Reminder // TI_SCHEDULER_005: Break Reminder
// ============================================================================ // ============================================================================
TEST_CASE("TI_SCHEDULER_005_BreakReminder", "[scheduler][integration]") { TEST_CASE("TI_SCHEDULER_005_BreakReminder", "[scheduler][integration]") {
SchedulerTestFixture f; SchedulerTestFixture f;
f.configure({{"breakReminderIntervalMinutes", 45}}); f.configure({{"breakReminderIntervalMinutes", 45}});
// Process at time 0 (sets lastBreakTime) // Process at time 0 (sets lastBreakTime)
f.processWithTime(0.0f); f.processWithTime(0.0f);
f.io.clearPublished(); f.io.clearPublished();
// Advance past break reminder interval (46 minutes = 2760 seconds) // Advance past break reminder interval (46 minutes = 2760 seconds)
f.processWithTime(2760.0f); f.processWithTime(2760.0f);
// Verify break reminder // Verify break reminder
REQUIRE(f.io.wasPublished("scheduler:break_reminder")); REQUIRE(f.io.wasPublished("scheduler:break_reminder"));
auto msg = f.io.getLastPublished("scheduler:break_reminder"); auto msg = f.io.getLastPublished("scheduler:break_reminder");
REQUIRE(msg["type"] == "break"); REQUIRE(msg["type"] == "break");
REQUIRE(msg.contains("break_duration")); REQUIRE(msg.contains("break_duration"));
} }
// ============================================================================ // ============================================================================
// TI_SCHEDULER_006: Idle Pauses Session // TI_SCHEDULER_006: Idle Pauses Session
// ============================================================================ // ============================================================================
TEST_CASE("TI_SCHEDULER_006_IdlePausesSession", "[scheduler][integration]") { TEST_CASE("TI_SCHEDULER_006_IdlePausesSession", "[scheduler][integration]") {
SchedulerTestFixture f; SchedulerTestFixture f;
f.configure(); f.configure();
// Start task // Start task
f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}});
f.processWithTime(0.0f); f.processWithTime(0.0f);
// Go idle // Go idle
f.io.injectMessage("monitoring:idle_detected", {{"idleSeconds", 300}}); f.io.injectMessage("monitoring:idle_detected", {{"idleSeconds", 300}});
f.processWithTime(60.0f); f.processWithTime(60.0f);
// Verify module received and processed the idle message // Verify module received and processed the idle message
// (Module logs "User idle" - we can verify via state) // (Module logs "User idle" - we can verify via state)
auto state = f.module.getState(); auto state = f.module.getState();
REQUIRE(state != nullptr); REQUIRE(state != nullptr);
// Task should still be tracked (idle doesn't clear it) // Task should still be tracked (idle doesn't clear it)
REQUIRE(state->getString("currentTaskId", "") == "task-1"); REQUIRE(state->getString("currentTaskId", "") == "task-1");
} }
// ============================================================================ // ============================================================================
// TI_SCHEDULER_007: Activity Resumes Session // TI_SCHEDULER_007: Activity Resumes Session
// ============================================================================ // ============================================================================
TEST_CASE("TI_SCHEDULER_007_ActivityResumesSession", "[scheduler][integration]") { TEST_CASE("TI_SCHEDULER_007_ActivityResumesSession", "[scheduler][integration]") {
SchedulerTestFixture f; SchedulerTestFixture f;
f.configure(); f.configure();
// Start task, go idle, resume // Start task, go idle, resume
f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}});
f.processWithTime(0.0f); f.processWithTime(0.0f);
f.io.injectMessage("monitoring:idle_detected", {}); f.io.injectMessage("monitoring:idle_detected", {});
f.processWithTime(60.0f); f.processWithTime(60.0f);
f.io.injectMessage("monitoring:activity_resumed", {}); f.io.injectMessage("monitoring:activity_resumed", {});
f.processWithTime(120.0f); f.processWithTime(120.0f);
// Verify session continues - task still active // Verify session continues - task still active
auto state = f.module.getState(); auto state = f.module.getState();
REQUIRE(state != nullptr); REQUIRE(state != nullptr);
REQUIRE(state->getString("currentTaskId", "") == "task-1"); REQUIRE(state->getString("currentTaskId", "") == "task-1");
} }
// ============================================================================ // ============================================================================
// TI_SCHEDULER_008: Tool Query Get Current Task // TI_SCHEDULER_008: Tool Query Get Current Task
// ============================================================================ // ============================================================================
TEST_CASE("TI_SCHEDULER_008_ToolQueryGetCurrentTask", "[scheduler][integration]") { TEST_CASE("TI_SCHEDULER_008_ToolQueryGetCurrentTask", "[scheduler][integration]") {
SchedulerTestFixture f; SchedulerTestFixture f;
f.configure(); f.configure();
// Start a task // Start a task
f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}});
f.processWithTime(0.0f); f.processWithTime(0.0f);
f.io.clearPublished(); f.io.clearPublished();
// Query current task // Query current task
f.io.injectMessage("scheduler:query", { f.io.injectMessage("scheduler:query", {
{"action", "get_current_task"}, {"action", "get_current_task"},
{"correlation_id", "test-123"} {"correlation_id", "test-123"}
}); });
f.processWithTime(60.0f); f.processWithTime(60.0f);
// Verify response // Verify response
REQUIRE(f.io.wasPublished("scheduler:response")); REQUIRE(f.io.wasPublished("scheduler:response"));
auto resp = f.io.getLastPublished("scheduler:response"); auto resp = f.io.getLastPublished("scheduler:response");
REQUIRE(resp["correlation_id"] == "test-123"); REQUIRE(resp["correlation_id"] == "test-123");
REQUIRE(resp["task_id"] == "task-1"); REQUIRE(resp["task_id"] == "task-1");
} }
// ============================================================================ // ============================================================================
// TI_SCHEDULER_009: Tool Command Start Break // TI_SCHEDULER_009: Tool Command Start Break
// ============================================================================ // ============================================================================
TEST_CASE("TI_SCHEDULER_009_ToolCommandStartBreak", "[scheduler][integration]") { TEST_CASE("TI_SCHEDULER_009_ToolCommandStartBreak", "[scheduler][integration]") {
SchedulerTestFixture f; SchedulerTestFixture f;
f.configure(); f.configure();
// Start task // Start task
f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}});
f.processWithTime(0.0f); f.processWithTime(0.0f);
f.io.clearPublished(); f.io.clearPublished();
// Command to start break // Command to start break
f.io.injectMessage("scheduler:command", { f.io.injectMessage("scheduler:command", {
{"action", "start_break"}, {"action", "start_break"},
{"duration_minutes", 15}, {"duration_minutes", 15},
{"reason", "test break"} {"reason", "test break"}
}); });
f.processWithTime(60.0f); f.processWithTime(60.0f);
// Verify break started was published // Verify break started was published
REQUIRE(f.io.wasPublished("scheduler:break_started")); REQUIRE(f.io.wasPublished("scheduler:break_started"));
auto msg = f.io.getLastPublished("scheduler:break_started"); auto msg = f.io.getLastPublished("scheduler:break_started");
REQUIRE(msg["duration"] == 15); REQUIRE(msg["duration"] == 15);
REQUIRE(msg["reason"] == "test break"); REQUIRE(msg["reason"] == "test break");
// Verify response was also published // Verify response was also published
REQUIRE(f.io.wasPublished("scheduler:response")); REQUIRE(f.io.wasPublished("scheduler:response"));
auto resp = f.io.getLastPublished("scheduler:response"); auto resp = f.io.getLastPublished("scheduler:response");
REQUIRE(resp["success"] == true); REQUIRE(resp["success"] == true);
} }
// ============================================================================ // ============================================================================
// TI_SCHEDULER_010: State Serialization // TI_SCHEDULER_010: State Serialization
// ============================================================================ // ============================================================================
TEST_CASE("TI_SCHEDULER_010_StateSerialization", "[scheduler][integration]") { TEST_CASE("TI_SCHEDULER_010_StateSerialization", "[scheduler][integration]") {
SchedulerTestFixture f; SchedulerTestFixture f;
f.configure(); f.configure();
// Setup some state // Setup some state
f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}}); f.io.injectMessage("user:task_switch", {{"taskId", "task-1"}});
f.processWithTime(0.0f); f.processWithTime(0.0f);
f.processWithTime(1800.0f); // 30 minutes f.processWithTime(1800.0f); // 30 minutes
// Get state // Get state
auto state = f.module.getState(); auto state = f.module.getState();
REQUIRE(state != nullptr); REQUIRE(state != nullptr);
// Verify state content // Verify state content
REQUIRE(state->getString("currentTaskId", "") == "task-1"); REQUIRE(state->getString("currentTaskId", "") == "task-1");
REQUIRE(state->getBool("hyperfocusAlertSent", true) == false); REQUIRE(state->getBool("hyperfocusAlertSent", true) == false);
// Create new module and restore state // Create new module and restore state
SchedulerModule module2; SchedulerModule module2;
MockIO io2; MockIO io2;
grove::JsonDataNode configNode("config", json::object()); grove::JsonDataNode configNode("config", json::object());
module2.setConfiguration(configNode, &io2, nullptr); module2.setConfiguration(configNode, &io2, nullptr);
module2.setState(*state); module2.setState(*state);
// Verify state was restored // Verify state was restored
auto state2 = module2.getState(); auto state2 = module2.getState();
REQUIRE(state2 != nullptr); REQUIRE(state2 != nullptr);
REQUIRE(state2->getString("currentTaskId", "") == "task-1"); REQUIRE(state2->getString("currentTaskId", "") == "task-1");
REQUIRE(state2->getBool("hyperfocusAlertSent", true) == false); REQUIRE(state2->getBool("hyperfocusAlertSent", true) == false);
} }

View File

@ -1,293 +1,293 @@
/** /**
* @file StorageModuleTests.cpp * @file StorageModuleTests.cpp
* @brief Integration tests for StorageModule (10 TI) * @brief Integration tests for StorageModule (10 TI)
*/ */
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include "mocks/MockIO.hpp" #include "mocks/MockIO.hpp"
#include "utils/TimeSimulator.hpp" #include "utils/TimeSimulator.hpp"
#include "utils/TestHelpers.hpp" #include "utils/TestHelpers.hpp"
#include "modules/StorageModule.h" #include "modules/StorageModule.h"
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
using namespace aissia; using namespace aissia;
using namespace aissia::tests; using namespace aissia::tests;
// ============================================================================ // ============================================================================
// Test Fixture // Test Fixture
// ============================================================================ // ============================================================================
class StorageTestFixture { class StorageTestFixture {
public: public:
MockIO io; MockIO io;
TimeSimulator time; TimeSimulator time;
StorageModule module; StorageModule module;
void configure(const json& config = json::object()) { void configure(const json& config = json::object()) {
json fullConfig = json::object(); json fullConfig = json::object();
fullConfig.merge_patch(config); fullConfig.merge_patch(config);
grove::JsonDataNode configNode("config", fullConfig); grove::JsonDataNode configNode("config", fullConfig);
module.setConfiguration(configNode, &io, nullptr); module.setConfiguration(configNode, &io, nullptr);
} }
void process() { void process() {
grove::JsonDataNode input("input", time.createInput()); grove::JsonDataNode input("input", time.createInput());
module.process(input); module.process(input);
} }
}; };
// ============================================================================ // ============================================================================
// TI_STORAGE_001: Task Completed Saves Session // TI_STORAGE_001: Task Completed Saves Session
// ============================================================================ // ============================================================================
TEST_CASE("TI_STORAGE_001_TaskCompletedSavesSession", "[storage][integration]") { TEST_CASE("TI_STORAGE_001_TaskCompletedSavesSession", "[storage][integration]") {
StorageTestFixture f; StorageTestFixture f;
f.configure(); f.configure();
// Receive task completed // Receive task completed
f.io.injectMessage("scheduler:task_completed", { f.io.injectMessage("scheduler:task_completed", {
{"taskId", "task-1"}, {"taskId", "task-1"},
{"taskName", "Coding session"}, {"taskName", "Coding session"},
{"durationMinutes", 45}, {"durationMinutes", 45},
{"hyperfocus", false} {"hyperfocus", false}
}); });
f.process(); f.process();
// Verify save_session published // Verify save_session published
REQUIRE(f.io.wasPublished("storage:save_session")); REQUIRE(f.io.wasPublished("storage:save_session"));
auto msg = f.io.getLastPublished("storage:save_session"); auto msg = f.io.getLastPublished("storage:save_session");
REQUIRE(msg["taskName"] == "Coding session"); REQUIRE(msg["taskName"] == "Coding session");
REQUIRE(msg["durationMinutes"] == 45); REQUIRE(msg["durationMinutes"] == 45);
} }
// ============================================================================ // ============================================================================
// TI_STORAGE_002: App Changed Saves Usage // TI_STORAGE_002: App Changed Saves Usage
// ============================================================================ // ============================================================================
TEST_CASE("TI_STORAGE_002_AppChangedSavesUsage", "[storage][integration]") { TEST_CASE("TI_STORAGE_002_AppChangedSavesUsage", "[storage][integration]") {
StorageTestFixture f; StorageTestFixture f;
f.configure(); f.configure();
// Receive app changed with duration // Receive app changed with duration
f.io.injectMessage("monitoring:app_changed", { f.io.injectMessage("monitoring:app_changed", {
{"appName", "Code"}, {"appName", "Code"},
{"oldApp", "Discord"}, {"oldApp", "Discord"},
{"duration", 120}, {"duration", 120},
{"classification", "productive"} {"classification", "productive"}
}); });
f.process(); f.process();
// Verify save_app_usage published // Verify save_app_usage published
REQUIRE(f.io.wasPublished("storage:save_app_usage")); REQUIRE(f.io.wasPublished("storage:save_app_usage"));
auto msg = f.io.getLastPublished("storage:save_app_usage"); auto msg = f.io.getLastPublished("storage:save_app_usage");
REQUIRE(msg["appName"] == "Discord"); // Old app that ended REQUIRE(msg["appName"] == "Discord"); // Old app that ended
REQUIRE(msg["durationSeconds"] == 120); REQUIRE(msg["durationSeconds"] == 120);
} }
// ============================================================================ // ============================================================================
// TI_STORAGE_003: Session Saved Updates Last ID // TI_STORAGE_003: Session Saved Updates Last ID
// ============================================================================ // ============================================================================
TEST_CASE("TI_STORAGE_003_SessionSavedUpdatesLastId", "[storage][integration]") { TEST_CASE("TI_STORAGE_003_SessionSavedUpdatesLastId", "[storage][integration]") {
StorageTestFixture f; StorageTestFixture f;
f.configure(); f.configure();
// Receive session saved confirmation // Receive session saved confirmation
f.io.injectMessage("storage:session_saved", { f.io.injectMessage("storage:session_saved", {
{"sessionId", 42} {"sessionId", 42}
}); });
f.process(); f.process();
// Verify state updated // Verify state updated
auto state = f.module.getState(); auto state = f.module.getState();
// TODO: Verify lastSessionId == 42 // TODO: Verify lastSessionId == 42
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_STORAGE_004: Storage Error Handled // TI_STORAGE_004: Storage Error Handled
// ============================================================================ // ============================================================================
TEST_CASE("TI_STORAGE_004_StorageErrorHandled", "[storage][integration]") { TEST_CASE("TI_STORAGE_004_StorageErrorHandled", "[storage][integration]") {
StorageTestFixture f; StorageTestFixture f;
f.configure(); f.configure();
// Receive storage error // Receive storage error
f.io.injectMessage("storage:error", { f.io.injectMessage("storage:error", {
{"message", "Database locked"} {"message", "Database locked"}
}); });
// Should not throw // Should not throw
REQUIRE_NOTHROW(f.process()); REQUIRE_NOTHROW(f.process());
} }
// ============================================================================ // ============================================================================
// TI_STORAGE_005: Pending Saves Tracking // TI_STORAGE_005: Pending Saves Tracking
// ============================================================================ // ============================================================================
TEST_CASE("TI_STORAGE_005_PendingSavesTracking", "[storage][integration]") { TEST_CASE("TI_STORAGE_005_PendingSavesTracking", "[storage][integration]") {
StorageTestFixture f; StorageTestFixture f;
f.configure(); f.configure();
// Trigger save // Trigger save
f.io.injectMessage("scheduler:task_completed", { f.io.injectMessage("scheduler:task_completed", {
{"taskId", "t1"}, {"taskId", "t1"},
{"taskName", "Task"}, {"taskName", "Task"},
{"durationMinutes", 10} {"durationMinutes", 10}
}); });
f.process(); f.process();
// Verify pending incremented // Verify pending incremented
auto state = f.module.getState(); auto state = f.module.getState();
// TODO: Verify pendingSaves == 1 // TODO: Verify pendingSaves == 1
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_STORAGE_006: Total Saved Tracking // TI_STORAGE_006: Total Saved Tracking
// ============================================================================ // ============================================================================
TEST_CASE("TI_STORAGE_006_TotalSavedTracking", "[storage][integration]") { TEST_CASE("TI_STORAGE_006_TotalSavedTracking", "[storage][integration]") {
StorageTestFixture f; StorageTestFixture f;
f.configure(); f.configure();
// Save and confirm multiple times // Save and confirm multiple times
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
f.io.injectMessage("scheduler:task_completed", { f.io.injectMessage("scheduler:task_completed", {
{"taskId", "t" + std::to_string(i)}, {"taskId", "t" + std::to_string(i)},
{"taskName", "Task"}, {"taskName", "Task"},
{"durationMinutes", 10} {"durationMinutes", 10}
}); });
f.process(); f.process();
f.io.injectMessage("storage:session_saved", {{"sessionId", i}}); f.io.injectMessage("storage:session_saved", {{"sessionId", i}});
f.process(); f.process();
} }
// Verify total // Verify total
auto state = f.module.getState(); auto state = f.module.getState();
// TODO: Verify totalSaved == 3 // TODO: Verify totalSaved == 3
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_STORAGE_007: Tool Query Notes // TI_STORAGE_007: Tool Query Notes
// ============================================================================ // ============================================================================
TEST_CASE("TI_STORAGE_007_ToolQueryNotes", "[storage][integration]") { TEST_CASE("TI_STORAGE_007_ToolQueryNotes", "[storage][integration]") {
StorageTestFixture f; StorageTestFixture f;
f.configure(); f.configure();
// Add a note first // Add a note first
f.io.injectMessage("storage:command", { f.io.injectMessage("storage:command", {
{"action", "save_note"}, {"action", "save_note"},
{"content", "Test note"}, {"content", "Test note"},
{"tags", json::array({"test", "important"})} {"tags", json::array({"test", "important"})}
}); });
f.process(); f.process();
f.io.clearPublished(); f.io.clearPublished();
// Query notes // Query notes
f.io.injectMessage("storage:query", { f.io.injectMessage("storage:query", {
{"action", "query_notes"}, {"action", "query_notes"},
{"correlation_id", "query-1"} {"correlation_id", "query-1"}
}); });
f.process(); f.process();
// Verify response // Verify response
REQUIRE(f.io.wasPublished("storage:response")); REQUIRE(f.io.wasPublished("storage:response"));
auto resp = f.io.getLastPublished("storage:response"); auto resp = f.io.getLastPublished("storage:response");
REQUIRE(resp["correlation_id"] == "query-1"); REQUIRE(resp["correlation_id"] == "query-1");
} }
// ============================================================================ // ============================================================================
// TI_STORAGE_008: Tool Command Save Note // TI_STORAGE_008: Tool Command Save Note
// ============================================================================ // ============================================================================
TEST_CASE("TI_STORAGE_008_ToolCommandSaveNote", "[storage][integration]") { TEST_CASE("TI_STORAGE_008_ToolCommandSaveNote", "[storage][integration]") {
StorageTestFixture f; StorageTestFixture f;
f.configure(); f.configure();
// Save note // Save note
f.io.injectMessage("storage:command", { f.io.injectMessage("storage:command", {
{"action", "save_note"}, {"action", "save_note"},
{"content", "Remember to check logs"}, {"content", "Remember to check logs"},
{"tags", json::array({"reminder"})} {"tags", json::array({"reminder"})}
}); });
f.process(); f.process();
// Verify note added to state // Verify note added to state
auto state = f.module.getState(); auto state = f.module.getState();
// TODO: Verify notes contains the new note // TODO: Verify notes contains the new note
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_STORAGE_009: Note Tags Filtering // TI_STORAGE_009: Note Tags Filtering
// ============================================================================ // ============================================================================
TEST_CASE("TI_STORAGE_009_NoteTagsFiltering", "[storage][integration]") { TEST_CASE("TI_STORAGE_009_NoteTagsFiltering", "[storage][integration]") {
StorageTestFixture f; StorageTestFixture f;
f.configure(); f.configure();
// Add notes with different tags // Add notes with different tags
f.io.injectMessage("storage:command", { f.io.injectMessage("storage:command", {
{"action", "save_note"}, {"action", "save_note"},
{"content", "Work note"}, {"content", "Work note"},
{"tags", json::array({"work"})} {"tags", json::array({"work"})}
}); });
f.process(); f.process();
f.io.injectMessage("storage:command", { f.io.injectMessage("storage:command", {
{"action", "save_note"}, {"action", "save_note"},
{"content", "Personal note"}, {"content", "Personal note"},
{"tags", json::array({"personal"})} {"tags", json::array({"personal"})}
}); });
f.process(); f.process();
f.io.clearPublished(); f.io.clearPublished();
// Query with tag filter // Query with tag filter
f.io.injectMessage("storage:query", { f.io.injectMessage("storage:query", {
{"action", "query_notes"}, {"action", "query_notes"},
{"tags", json::array({"work"})}, {"tags", json::array({"work"})},
{"correlation_id", "filter-1"} {"correlation_id", "filter-1"}
}); });
f.process(); f.process();
// Verify filtered response // Verify filtered response
REQUIRE(f.io.wasPublished("storage:response")); REQUIRE(f.io.wasPublished("storage:response"));
auto resp = f.io.getLastPublished("storage:response"); auto resp = f.io.getLastPublished("storage:response");
// TODO: Verify only work notes returned // TODO: Verify only work notes returned
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_STORAGE_010: State Serialization // TI_STORAGE_010: State Serialization
// ============================================================================ // ============================================================================
TEST_CASE("TI_STORAGE_010_StateSerialization", "[storage][integration]") { TEST_CASE("TI_STORAGE_010_StateSerialization", "[storage][integration]") {
StorageTestFixture f; StorageTestFixture f;
f.configure(); f.configure();
// Build state with notes // Build state with notes
f.io.injectMessage("storage:command", { f.io.injectMessage("storage:command", {
{"action", "save_note"}, {"action", "save_note"},
{"content", "Test note for serialization"}, {"content", "Test note for serialization"},
{"tags", json::array({"test"})} {"tags", json::array({"test"})}
}); });
f.process(); f.process();
// Get state // Get state
auto state = f.module.getState(); auto state = f.module.getState();
REQUIRE(state != nullptr); REQUIRE(state != nullptr);
// Restore // Restore
StorageModule module2; StorageModule module2;
grove::JsonDataNode configNode2("config", json::object()); grove::JsonDataNode configNode2("config", json::object());
module2.setConfiguration(configNode2, &f.io, nullptr); module2.setConfiguration(configNode2, &f.io, nullptr);
module2.setState(*state); module2.setState(*state);
auto state2 = module2.getState(); auto state2 = module2.getState();
REQUIRE(state2 != nullptr); REQUIRE(state2 != nullptr);
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }

View File

@ -1,258 +1,258 @@
/** /**
* @file VoiceModuleTests.cpp * @file VoiceModuleTests.cpp
* @brief Integration tests for VoiceModule (10 TI) * @brief Integration tests for VoiceModule (10 TI)
*/ */
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include "mocks/MockIO.hpp" #include "mocks/MockIO.hpp"
#include "utils/TimeSimulator.hpp" #include "utils/TimeSimulator.hpp"
#include "utils/TestHelpers.hpp" #include "utils/TestHelpers.hpp"
#include "modules/VoiceModule.h" #include "modules/VoiceModule.h"
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
using namespace aissia; using namespace aissia;
using namespace aissia::tests; using namespace aissia::tests;
// ============================================================================ // ============================================================================
// Test Fixture // Test Fixture
// ============================================================================ // ============================================================================
class VoiceTestFixture { class VoiceTestFixture {
public: public:
MockIO io; MockIO io;
TimeSimulator time; TimeSimulator time;
VoiceModule module; VoiceModule module;
void configure(const json& config = json::object()) { void configure(const json& config = json::object()) {
json fullConfig = { json fullConfig = {
{"ttsEnabled", true}, {"ttsEnabled", true},
{"sttEnabled", true}, {"sttEnabled", true},
{"language", "fr"} {"language", "fr"}
}; };
fullConfig.merge_patch(config); fullConfig.merge_patch(config);
grove::JsonDataNode configNode("config", fullConfig); grove::JsonDataNode configNode("config", fullConfig);
module.setConfiguration(configNode, &io, nullptr); module.setConfiguration(configNode, &io, nullptr);
} }
void process() { void process() {
grove::JsonDataNode input("input", time.createInput()); grove::JsonDataNode input("input", time.createInput());
module.process(input); module.process(input);
} }
}; };
// ============================================================================ // ============================================================================
// TI_VOICE_001: AI Response Triggers Speak // TI_VOICE_001: AI Response Triggers Speak
// ============================================================================ // ============================================================================
TEST_CASE("TI_VOICE_001_AIResponseTriggersSpeak", "[voice][integration]") { TEST_CASE("TI_VOICE_001_AIResponseTriggersSpeak", "[voice][integration]") {
VoiceTestFixture f; VoiceTestFixture f;
f.configure(); f.configure();
// Receive AI response // Receive AI response
f.io.injectMessage("ai:response", { f.io.injectMessage("ai:response", {
{"text", "Voici la reponse a ta question"} {"text", "Voici la reponse a ta question"}
}); });
f.process(); f.process();
// Verify speak request // Verify speak request
REQUIRE(f.io.wasPublished("voice:speak")); REQUIRE(f.io.wasPublished("voice:speak"));
auto msg = f.io.getLastPublished("voice:speak"); auto msg = f.io.getLastPublished("voice:speak");
REQUIRE(msg["text"] == "Voici la reponse a ta question"); REQUIRE(msg["text"] == "Voici la reponse a ta question");
} }
// ============================================================================ // ============================================================================
// TI_VOICE_002: Suggestion Priority Speak // TI_VOICE_002: Suggestion Priority Speak
// ============================================================================ // ============================================================================
TEST_CASE("TI_VOICE_002_SuggestionPrioritySpeak", "[voice][integration]") { TEST_CASE("TI_VOICE_002_SuggestionPrioritySpeak", "[voice][integration]") {
VoiceTestFixture f; VoiceTestFixture f;
f.configure(); f.configure();
// Receive suggestion (should be priority) // Receive suggestion (should be priority)
f.io.injectMessage("ai:suggestion", { f.io.injectMessage("ai:suggestion", {
{"message", "Tu devrais faire une pause"}, {"message", "Tu devrais faire une pause"},
{"duration", 5} {"duration", 5}
}); });
f.process(); f.process();
// Verify speak with priority // Verify speak with priority
REQUIRE(f.io.wasPublished("voice:speak")); REQUIRE(f.io.wasPublished("voice:speak"));
auto msg = f.io.getLastPublished("voice:speak"); auto msg = f.io.getLastPublished("voice:speak");
REQUIRE(msg["priority"] == true); REQUIRE(msg["priority"] == true);
} }
// ============================================================================ // ============================================================================
// TI_VOICE_003: Speaking Started Updates State // TI_VOICE_003: Speaking Started Updates State
// ============================================================================ // ============================================================================
TEST_CASE("TI_VOICE_003_SpeakingStartedUpdatesState", "[voice][integration]") { TEST_CASE("TI_VOICE_003_SpeakingStartedUpdatesState", "[voice][integration]") {
VoiceTestFixture f; VoiceTestFixture f;
f.configure(); f.configure();
// Initially idle // Initially idle
REQUIRE(f.module.isIdle() == true); REQUIRE(f.module.isIdle() == true);
// Receive speaking started // Receive speaking started
f.io.injectMessage("voice:speaking_started", {{"text", "Hello"}}); f.io.injectMessage("voice:speaking_started", {{"text", "Hello"}});
f.process(); f.process();
// Should be speaking // Should be speaking
REQUIRE(f.module.isIdle() == false); REQUIRE(f.module.isIdle() == false);
} }
// ============================================================================ // ============================================================================
// TI_VOICE_004: Speaking Ended Updates State // TI_VOICE_004: Speaking Ended Updates State
// ============================================================================ // ============================================================================
TEST_CASE("TI_VOICE_004_SpeakingEndedUpdatesState", "[voice][integration]") { TEST_CASE("TI_VOICE_004_SpeakingEndedUpdatesState", "[voice][integration]") {
VoiceTestFixture f; VoiceTestFixture f;
f.configure(); f.configure();
// Start speaking // Start speaking
f.io.injectMessage("voice:speaking_started", {{"text", "Hello"}}); f.io.injectMessage("voice:speaking_started", {{"text", "Hello"}});
f.process(); f.process();
REQUIRE(f.module.isIdle() == false); REQUIRE(f.module.isIdle() == false);
// End speaking // End speaking
f.io.injectMessage("voice:speaking_ended", {}); f.io.injectMessage("voice:speaking_ended", {});
f.process(); f.process();
// Should be idle // Should be idle
REQUIRE(f.module.isIdle() == true); REQUIRE(f.module.isIdle() == true);
} }
// ============================================================================ // ============================================================================
// TI_VOICE_005: IsIdle Reflects Speaking // TI_VOICE_005: IsIdle Reflects Speaking
// ============================================================================ // ============================================================================
TEST_CASE("TI_VOICE_005_IsIdleReflectsSpeaking", "[voice][integration]") { TEST_CASE("TI_VOICE_005_IsIdleReflectsSpeaking", "[voice][integration]") {
VoiceTestFixture f; VoiceTestFixture f;
f.configure(); f.configure();
// Not speaking = idle // Not speaking = idle
REQUIRE(f.module.isIdle() == true); REQUIRE(f.module.isIdle() == true);
// Start speaking // Start speaking
f.io.injectMessage("voice:speaking_started", {}); f.io.injectMessage("voice:speaking_started", {});
f.process(); f.process();
REQUIRE(f.module.isIdle() == false); REQUIRE(f.module.isIdle() == false);
// Stop speaking // Stop speaking
f.io.injectMessage("voice:speaking_ended", {}); f.io.injectMessage("voice:speaking_ended", {});
f.process(); f.process();
REQUIRE(f.module.isIdle() == true); REQUIRE(f.module.isIdle() == true);
} }
// ============================================================================ // ============================================================================
// TI_VOICE_006: Transcription Forwarded (No Re-publish) // TI_VOICE_006: Transcription Forwarded (No Re-publish)
// ============================================================================ // ============================================================================
TEST_CASE("TI_VOICE_006_TranscriptionForwarded", "[voice][integration]") { TEST_CASE("TI_VOICE_006_TranscriptionForwarded", "[voice][integration]") {
VoiceTestFixture f; VoiceTestFixture f;
f.configure(); f.configure();
// Receive transcription // Receive transcription
f.io.injectMessage("voice:transcription", { f.io.injectMessage("voice:transcription", {
{"text", "Test transcription"}, {"text", "Test transcription"},
{"confidence", 0.9} {"confidence", 0.9}
}); });
f.process(); f.process();
// VoiceModule should NOT re-publish transcription // VoiceModule should NOT re-publish transcription
// It just updates internal state // It just updates internal state
REQUIRE(f.io.countPublished("voice:transcription") == 0); REQUIRE(f.io.countPublished("voice:transcription") == 0);
} }
// ============================================================================ // ============================================================================
// TI_VOICE_007: Total Spoken Incremented // TI_VOICE_007: Total Spoken Incremented
// ============================================================================ // ============================================================================
TEST_CASE("TI_VOICE_007_TotalSpokenIncremented", "[voice][integration]") { TEST_CASE("TI_VOICE_007_TotalSpokenIncremented", "[voice][integration]") {
VoiceTestFixture f; VoiceTestFixture f;
f.configure(); f.configure();
// Complete one speak cycle // Complete one speak cycle
f.io.injectMessage("voice:speaking_started", {}); f.io.injectMessage("voice:speaking_started", {});
f.process(); f.process();
f.io.injectMessage("voice:speaking_ended", {}); f.io.injectMessage("voice:speaking_ended", {});
f.process(); f.process();
// Complete another // Complete another
f.io.injectMessage("voice:speaking_started", {}); f.io.injectMessage("voice:speaking_started", {});
f.process(); f.process();
f.io.injectMessage("voice:speaking_ended", {}); f.io.injectMessage("voice:speaking_ended", {});
f.process(); f.process();
// Verify counter // Verify counter
auto state = f.module.getState(); auto state = f.module.getState();
// TODO: Verify totalSpoken == 2 // TODO: Verify totalSpoken == 2
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }
// ============================================================================ // ============================================================================
// TI_VOICE_008: TTS Disabled Config // TI_VOICE_008: TTS Disabled Config
// ============================================================================ // ============================================================================
TEST_CASE("TI_VOICE_008_TTSDisabledConfig", "[voice][integration]") { TEST_CASE("TI_VOICE_008_TTSDisabledConfig", "[voice][integration]") {
VoiceTestFixture f; VoiceTestFixture f;
f.configure({{"ttsEnabled", false}}); f.configure({{"ttsEnabled", false}});
// Try to trigger speak // Try to trigger speak
f.io.injectMessage("ai:response", {{"text", "Should not speak"}}); f.io.injectMessage("ai:response", {{"text", "Should not speak"}});
f.process(); f.process();
// Should NOT publish speak request // Should NOT publish speak request
REQUIRE(f.io.wasPublished("voice:speak") == false); REQUIRE(f.io.wasPublished("voice:speak") == false);
} }
// ============================================================================ // ============================================================================
// TI_VOICE_009: Tool Command Speak // TI_VOICE_009: Tool Command Speak
// ============================================================================ // ============================================================================
TEST_CASE("TI_VOICE_009_ToolCommandSpeak", "[voice][integration]") { TEST_CASE("TI_VOICE_009_ToolCommandSpeak", "[voice][integration]") {
VoiceTestFixture f; VoiceTestFixture f;
f.configure(); f.configure();
// Send speak command via tool // Send speak command via tool
f.io.injectMessage("voice:command", { f.io.injectMessage("voice:command", {
{"action", "speak"}, {"action", "speak"},
{"text", "Hello from tool"} {"text", "Hello from tool"}
}); });
f.process(); f.process();
// Verify speak published // Verify speak published
REQUIRE(f.io.wasPublished("voice:speak")); REQUIRE(f.io.wasPublished("voice:speak"));
auto msg = f.io.getLastPublished("voice:speak"); auto msg = f.io.getLastPublished("voice:speak");
REQUIRE(msg["text"] == "Hello from tool"); REQUIRE(msg["text"] == "Hello from tool");
} }
// ============================================================================ // ============================================================================
// TI_VOICE_010: State Serialization // TI_VOICE_010: State Serialization
// ============================================================================ // ============================================================================
TEST_CASE("TI_VOICE_010_StateSerialization", "[voice][integration]") { TEST_CASE("TI_VOICE_010_StateSerialization", "[voice][integration]") {
VoiceTestFixture f; VoiceTestFixture f;
f.configure(); f.configure();
// Build state // Build state
f.io.injectMessage("voice:speaking_started", {}); f.io.injectMessage("voice:speaking_started", {});
f.process(); f.process();
f.io.injectMessage("voice:speaking_ended", {}); f.io.injectMessage("voice:speaking_ended", {});
f.process(); f.process();
// Get state // Get state
auto state = f.module.getState(); auto state = f.module.getState();
REQUIRE(state != nullptr); REQUIRE(state != nullptr);
// Restore // Restore
VoiceModule module2; VoiceModule module2;
grove::JsonDataNode configNode2("config", json::object()); grove::JsonDataNode configNode2("config", json::object());
module2.setConfiguration(configNode2, &f.io, nullptr); module2.setConfiguration(configNode2, &f.io, nullptr);
module2.setState(*state); module2.setState(*state);
auto state2 = module2.getState(); auto state2 = module2.getState();
REQUIRE(state2 != nullptr); REQUIRE(state2 != nullptr);
SUCCEED(); // Placeholder SUCCEED(); // Placeholder
} }

View File

@ -1,82 +1,82 @@
#pragma once #pragma once
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <string> #include <string>
namespace aissia::tests { namespace aissia::tests {
using json = nlohmann::json; using json = nlohmann::json;
// ============================================================================ // ============================================================================
// Custom Catch2 Matchers and Macros // Custom Catch2 Matchers and Macros
// ============================================================================ // ============================================================================
/** /**
* @brief Require that a message was published to a topic * @brief Require that a message was published to a topic
*/ */
#define REQUIRE_PUBLISHED(io, topic) \ #define REQUIRE_PUBLISHED(io, topic) \
REQUIRE_MESSAGE(io.wasPublished(topic), "Expected message on topic: " << topic) REQUIRE_MESSAGE(io.wasPublished(topic), "Expected message on topic: " << topic)
/** /**
* @brief Require that no message was published to a topic * @brief Require that no message was published to a topic
*/ */
#define REQUIRE_NOT_PUBLISHED(io, topic) \ #define REQUIRE_NOT_PUBLISHED(io, topic) \
REQUIRE_MESSAGE(!io.wasPublished(topic), "Did not expect message on topic: " << topic) REQUIRE_MESSAGE(!io.wasPublished(topic), "Did not expect message on topic: " << topic)
/** /**
* @brief Require specific count of messages on a topic * @brief Require specific count of messages on a topic
*/ */
#define REQUIRE_PUBLISH_COUNT(io, topic, count) \ #define REQUIRE_PUBLISH_COUNT(io, topic, count) \
REQUIRE(io.countPublished(topic) == count) REQUIRE(io.countPublished(topic) == count)
// ============================================================================ // ============================================================================
// JSON Helpers // JSON Helpers
// ============================================================================ // ============================================================================
/** /**
* @brief Create a minimal valid config for a module * @brief Create a minimal valid config for a module
*/ */
inline json makeConfig(const json& overrides = json::object()) { inline json makeConfig(const json& overrides = json::object()) {
json config = json::object(); json config = json::object();
for (auto& [key, value] : overrides.items()) { for (auto& [key, value] : overrides.items()) {
config[key] = value; config[key] = value;
} }
return config; return config;
} }
/** /**
* @brief Check if JSON contains expected fields * @brief Check if JSON contains expected fields
*/ */
inline bool jsonContains(const json& j, const json& expected) { inline bool jsonContains(const json& j, const json& expected) {
for (auto& [key, value] : expected.items()) { for (auto& [key, value] : expected.items()) {
if (!j.contains(key) || j[key] != value) { if (!j.contains(key) || j[key] != value) {
return false; return false;
} }
} }
return true; return true;
} }
// ============================================================================ // ============================================================================
// Test Tags // Test Tags
// ============================================================================ // ============================================================================
// Module tags // Module tags
constexpr const char* TAG_SCHEDULER = "[scheduler]"; constexpr const char* TAG_SCHEDULER = "[scheduler]";
constexpr const char* TAG_NOTIFICATION = "[notification]"; constexpr const char* TAG_NOTIFICATION = "[notification]";
constexpr const char* TAG_MONITORING = "[monitoring]"; constexpr const char* TAG_MONITORING = "[monitoring]";
constexpr const char* TAG_AI = "[ai]"; constexpr const char* TAG_AI = "[ai]";
constexpr const char* TAG_VOICE = "[voice]"; constexpr const char* TAG_VOICE = "[voice]";
constexpr const char* TAG_STORAGE = "[storage]"; constexpr const char* TAG_STORAGE = "[storage]";
// MCP tags // MCP tags
constexpr const char* TAG_MCP = "[mcp]"; constexpr const char* TAG_MCP = "[mcp]";
constexpr const char* TAG_MCP_TYPES = "[mcp][types]"; constexpr const char* TAG_MCP_TYPES = "[mcp][types]";
constexpr const char* TAG_MCP_TRANSPORT = "[mcp][transport]"; constexpr const char* TAG_MCP_TRANSPORT = "[mcp][transport]";
constexpr const char* TAG_MCP_CLIENT = "[mcp][client]"; constexpr const char* TAG_MCP_CLIENT = "[mcp][client]";
// Common tags // Common tags
constexpr const char* TAG_INTEGRATION = "[integration]"; constexpr const char* TAG_INTEGRATION = "[integration]";
constexpr const char* TAG_UNIT = "[unit]"; constexpr const char* TAG_UNIT = "[unit]";
} // namespace aissia::tests } // namespace aissia::tests

View File

@ -1,92 +1,92 @@
#pragma once #pragma once
#include <grove/JsonDataNode.h> #include <grove/JsonDataNode.h>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <memory> #include <memory>
namespace aissia::tests { namespace aissia::tests {
using json = nlohmann::json; using json = nlohmann::json;
/** /**
* @brief Simulates game time for testing modules * @brief Simulates game time for testing modules
* *
* Modules receive time info via process() input: * Modules receive time info via process() input:
* { * {
* "gameTime": 123.45, // Total elapsed time in seconds * "gameTime": 123.45, // Total elapsed time in seconds
* "deltaTime": 0.1 // Time since last frame * "deltaTime": 0.1 // Time since last frame
* } * }
*/ */
class TimeSimulator { class TimeSimulator {
public: public:
TimeSimulator() = default; TimeSimulator() = default;
/** /**
* @brief Create input data for module.process() * @brief Create input data for module.process()
* @param deltaTime Time since last frame (default 0.1s = 10Hz) * @param deltaTime Time since last frame (default 0.1s = 10Hz)
*/ */
json createInput(float deltaTime = 0.1f) { json createInput(float deltaTime = 0.1f) {
json input = { json input = {
{"gameTime", m_gameTime}, {"gameTime", m_gameTime},
{"deltaTime", deltaTime} {"deltaTime", deltaTime}
}; };
m_gameTime += deltaTime; m_gameTime += deltaTime;
return input; return input;
} }
/** /**
* @brief Create input as IDataNode * @brief Create input as IDataNode
*/ */
std::unique_ptr<grove::JsonDataNode> createInputNode(float deltaTime = 0.1f) { std::unique_ptr<grove::JsonDataNode> createInputNode(float deltaTime = 0.1f) {
return std::make_unique<grove::JsonDataNode>("input", createInput(deltaTime)); return std::make_unique<grove::JsonDataNode>("input", createInput(deltaTime));
} }
/** /**
* @brief Advance time without creating input * @brief Advance time without creating input
*/ */
void advance(float seconds) { void advance(float seconds) {
m_gameTime += seconds; m_gameTime += seconds;
} }
/** /**
* @brief Advance time by minutes (convenience for hyperfocus tests) * @brief Advance time by minutes (convenience for hyperfocus tests)
*/ */
void advanceMinutes(float minutes) { void advanceMinutes(float minutes) {
m_gameTime += minutes * 60.0f; m_gameTime += minutes * 60.0f;
} }
/** /**
* @brief Set absolute time * @brief Set absolute time
*/ */
void setTime(float time) { void setTime(float time) {
m_gameTime = time; m_gameTime = time;
} }
/** /**
* @brief Get current game time * @brief Get current game time
*/ */
float getTime() const { float getTime() const {
return m_gameTime; return m_gameTime;
} }
/** /**
* @brief Reset to zero * @brief Reset to zero
*/ */
void reset() { void reset() {
m_gameTime = 0.0f; m_gameTime = 0.0f;
} }
/** /**
* @brief Simulate multiple frames * @brief Simulate multiple frames
* @param count Number of frames to simulate * @param count Number of frames to simulate
* @param deltaTime Time per frame * @param deltaTime Time per frame
*/ */
void simulateFrames(int count, float deltaTime = 0.1f) { void simulateFrames(int count, float deltaTime = 0.1f) {
m_gameTime += count * deltaTime; m_gameTime += count * deltaTime;
} }
private: private:
float m_gameTime = 0.0f; float m_gameTime = 0.0f;
}; };
} // namespace aissia::tests } // namespace aissia::tests