diff --git a/.env.example b/.env.example index 6905cd3..5001c10 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,12 @@ OPENAI_API_KEY=your_openai_api_key_here # Required if LLM_BACKEND=gemini GEMINI_API_KEY=your_gemini_api_key_here +# ========================= +# NOTION CONFIGURATION +# ========================= +NOTION_API_KEY= +NOTION_PARENT_PAGE_ID= + # ========================= # REDIS CONFIGURATION diff --git a/README.md b/README.md index 9042dcc..0d39e33 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,11 @@ The SvelteKit dashboard provides a futuristic UI showing real-time telemetry, li - ⚠️ Uncertainty flagging - 🐢 Safe Mode | ⚡ Expert Mode +### 🔗 7. Integrations & Export +- 📝 Export session traces directly to **Notion** +- 🔗 Auto-generate shareable execution reports +- 💾 Persistent AI action logs + @@ -648,6 +653,19 @@ npm install cd .. ``` +### Notion Integration Setup (Optional) + +To enable exporting your Execra sessions to a Notion page, you need to configure your API keys and grant access to your workspace: + +1. Go to [Notion Integrations](https://www.notion.so/my-integrations) and create a new internal integration. +2. Copy the **Internal Integration Secret**. +3. Create a `.env` file in the root directory (you can copy from `.env.example`). +4. Set the `NOTION_API_KEY` variable in your `.env` file to the integration secret you copied. +5. Create a target page in your Notion workspace to store the session logs. +6. Copy the **Page ID** from the page URL (the 32-character string at the end) and set it as `NOTION_PARENT_PAGE_ID` in your `.env` file. +7. **Crucial Step:** Open the target page in Notion. Click the **`...` (three dots)** menu in the top right corner, click on **Add connections**, search for your integration's name, and click on it to grant the integration content access to this page. Without this, exports will fail with a 404 error. + + ### Running the Services Locally #### 1. Start the FastAPI Backend @@ -820,6 +838,9 @@ F --> G --- +> [!IMPORTANT] +> **Please read our full [Contribution Guidelines (CONTRIBUTING.md)](CONTRIBUTING.md)** before writing any code. It contains essential details on local setup, our branching strategy, coding standards, and how GSSoC points are awarded. + ### 📝 Step-by-Step Contribution Guide ```bash diff --git a/api/main.py b/api/main.py index 7fffd45..7206a46 100644 --- a/api/main.py +++ b/api/main.py @@ -4,8 +4,9 @@ from fastapi.middleware.cors import CORSMiddleware from api.routes import status, mode -from api.routes import actions, context +from api.routes import actions, context, session from api.websockets import guidance as ws_guidance +from api.websockets import router as ws_router from core.config import settings from core.errors import handle_exception # ✅ NEW @@ -74,9 +75,11 @@ def read_root(): # Action log and session context endpoints app.include_router(actions.router, prefix="/api/v1") app.include_router(context.router, prefix="/api/v1") +app.include_router(session.router, prefix="/api/v1") # WebSocket endpoints (no prefix — WS routes use the path as-is) app.include_router(ws_guidance.router) +app.include_router(ws_router.router) # Alert suppression endpoints app.include_router(suppression.router, prefix="/api/v1") \ No newline at end of file diff --git a/api/routes/session.py b/api/routes/session.py new file mode 100644 index 0000000..c6634e8 --- /dev/null +++ b/api/routes/session.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from core.hybrid.action_logger import action_logger +from core.integrations.notion_exporter import NotionExporter +from core.config import settings +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + +class ExportSessionRequest(BaseModel): + session_id: str + +@router.post("/session/export/notion") +async def export_session_to_notion(request: ExportSessionRequest): + if not settings.NOTION_API_KEY or not settings.NOTION_PARENT_PAGE_ID: + raise HTTPException(status_code=500, detail="Notion configuration is missing (NOTION_API_KEY or NOTION_PARENT_PAGE_ID)") + + actions = await action_logger.get_actions_by_session(request.session_id) + if not actions: + raise HTTPException(status_code=404, detail=f"No actions found for session_id: {request.session_id}") + + exporter = NotionExporter(settings.NOTION_API_KEY) + try: + url = exporter.export_session(request.session_id, settings.NOTION_PARENT_PAGE_ID, actions) + return {"message": "Session exported successfully to Notion", "url": url} + except Exception as e: + logger.error(f"Failed to export session to Notion: {e}") + raise HTTPException(status_code=500, detail=f"Failed to export session: {str(e)}") diff --git a/core/config.py b/core/config.py index 9bcdb30..75e143d 100644 --- a/core/config.py +++ b/core/config.py @@ -27,6 +27,8 @@ class Settings: LLM_BACKEND: str = "openai" OPENAI_API_KEY: str = "" GEMINI_API_KEY: str = "" + NOTION_API_KEY: str = "" + NOTION_PARENT_PAGE_ID: str = "" # Security ENCRYPTION_KEY: str = "" @@ -90,6 +92,10 @@ def __post_init__(self): self.OPENAI_API_KEY = val if val := os.getenv("GEMINI_API_KEY"): self.GEMINI_API_KEY = val + if val := os.getenv("NOTION_API_KEY"): + self.NOTION_API_KEY = val + if val := os.getenv("NOTION_PARENT_PAGE_ID"): + self.NOTION_PARENT_PAGE_ID = val # Security if val := os.getenv("ENCRYPTION_KEY"): self.ENCRYPTION_KEY = val diff --git a/core/hybrid/action_logger.py b/core/hybrid/action_logger.py index 0d2f471..af028d6 100644 --- a/core/hybrid/action_logger.py +++ b/core/hybrid/action_logger.py @@ -124,6 +124,32 @@ async def get_history(self, limit: int = 20, offset: int = 0) -> list[ActionReco ) for row in rows ] + + async def get_actions_by_session(self, session_id: str) -> list[ActionRecord]: + """Fetch all actions for a specific session_id, ordered by timestamp ascending.""" + await self._init_db() + + async with aiosqlite.connect(self.db_path) as db: + cursor = await db.execute(""" + SELECT * FROM action_log + WHERE session_id = ? + ORDER BY timestamp ASC + """, (session_id,)) + rows = await cursor.fetchall() + + return [ + ActionRecord( + id=row[0], + session_id=row[1], + timestamp=datetime.fromisoformat(row[2]), + type=row[3], + description=row[4], + domain=row[5], + was_guided=bool(row[6]), + guidance_confidence=row[7] + ) + for row in rows + ] async def clear_session(self, session_id: str) -> None: """Delete all actions for the session from SQLite and clear the in-memory stack.""" await self._init_db() # ensure table exists diff --git a/core/integrations/notion_exporter.py b/core/integrations/notion_exporter.py new file mode 100644 index 0000000..a185640 --- /dev/null +++ b/core/integrations/notion_exporter.py @@ -0,0 +1,86 @@ +import logging +from typing import List +from notion_client import Client +from core.hybrid.action_logger import ActionRecord + +logger = logging.getLogger(__name__) + +class NotionExporter: + def __init__(self, api_key: str): + self.client = Client(auth=api_key) + + def _create_table_row(self, cells: List[str]) -> dict: + """Create a Notion table row block from a list of cell strings.""" + return { + "type": "table_row", + "table_row": { + "cells": [ + [{"type": "text", "text": {"content": str(cell)[:2000]}}] for cell in cells + ] + } + } + + def export_session(self, session_id: str, page_id: str, actions: List[ActionRecord]) -> str: + """ + Export a session's actions to a new Notion page. + Returns the URL of the created page. + """ + page_properties = { + "title": [ + { + "text": { + "content": f"Execra Session: {session_id}" + } + } + ] + } + + # Header row + headers = ["Timestamp", "Action Type", "Description", "Domain", "Guided", "Confidence"] + + # Notion API limits block children to 100 elements. + # A table block can have max 100 rows. We reserve 1 for header. + CHUNK_SIZE = 99 + + blocks = [] + for i in range(0, len(actions), CHUNK_SIZE): + chunk = actions[i:i + CHUNK_SIZE] + + table_children = [self._create_table_row(headers)] + for action in chunk: + confidence_str = f"{action.guidance_confidence:.2f}" if action.guidance_confidence is not None else "N/A" + row_data = [ + action.timestamp.isoformat(), + action.type, + action.description, + action.domain, + "Yes" if action.was_guided else "No", + confidence_str + ] + table_children.append(self._create_table_row(row_data)) + + table_block = { + "object": "block", + "type": "table", + "table": { + "table_width": 6, + "has_column_header": True, + "has_row_header": False, + "children": table_children + } + } + blocks.append(table_block) + + try: + response = self.client.pages.create( + parent={"page_id": page_id}, + properties=page_properties, + children=blocks + ) + new_page = dict(response) if isinstance(response, dict) else getattr(response, "__dict__", {}) + page_url = new_page.get('url', "") + logger.info(f"Successfully exported session {session_id} to Notion page: {page_url}") + return page_url + except Exception as e: + logger.error(f"Failed to export session {session_id} to Notion: {e}") + raise diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte index fe2816b..00ccf38 100644 --- a/dashboard/src/routes/+page.svelte +++ b/dashboard/src/routes/+page.svelte @@ -8,6 +8,9 @@ let historyError = $state(null); let isSendingAction = $state(false); let isUndoingAction = $state(false); + let isExportingNotion = $state(false); + let exportSuccessUrl = $state(null); + let exportErrorMsg = $state(null); // Form inputs for simulating a new action let simType = $state('user_click'); @@ -97,6 +100,42 @@ } } + // Export session to Notion via POST /api/v1/session/export/notion + async function handleExportNotion() { + if (isExportingNotion) return; + try { + isExportingNotion = true; + exportSuccessUrl = null; + exportErrorMsg = null; + const actions = allActions(); + const currentSessionId = actions.length > 0 ? actions[0].session_id : null; + + if (!currentSessionId) { + exportErrorMsg = 'No active session to export. Simulate an action first.'; + return; + } + + const res = await fetch('http://127.0.0.1:8000/api/v1/session/export/notion', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: currentSessionId }) + }); + + const data = await res.json(); + + if (!res.ok) { + exportErrorMsg = data.detail || res.statusText; + } else { + exportSuccessUrl = data.url; + } + } catch (err) { + console.error('Failed to export to Notion:', err); + exportErrorMsg = 'Network error while exporting to Notion. Please check your connection.'; + } finally { + isExportingNotion = false; + } + } + onMount(() => { wsService.connect(); fetchHistory(); @@ -446,6 +485,24 @@ REFRESH DB + +
+
+

Export to Notion

+

Sync session actions to Notion page

+
+ +
@@ -472,4 +529,59 @@ + + + {#if exportSuccessUrl} +
+
+
+
+ + + +
+

Export Successful!

+

Your Execra session has been securely synced to Notion.

+ +
+ Notion Page Link + + {exportSuccessUrl} + +
+ + +
+
+
+ {/if} + + + {#if exportErrorMsg} +
+
+
+
+ + + +
+

Export Failed

+

{exportErrorMsg}

+ + +
+
+
+ {/if} diff --git a/requirements.txt b/requirements.txt index 9a4bcbd..b4eb4f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ pydantic cryptography scikit-learn joblib +notion-client diff --git a/tests/api/test_session_api.py b/tests/api/test_session_api.py new file mode 100644 index 0000000..1c9c904 --- /dev/null +++ b/tests/api/test_session_api.py @@ -0,0 +1,35 @@ +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, AsyncMock, MagicMock +from api.main import app + +client = TestClient(app) + +@patch("api.routes.session.action_logger.get_actions_by_session", new_callable=AsyncMock) +@patch("api.routes.session.NotionExporter") +@patch("api.routes.session.settings") +def test_export_session_success(mock_settings, mock_exporter_class, mock_get_actions): + mock_settings.NOTION_API_KEY = "test-key" + mock_settings.NOTION_PARENT_PAGE_ID = "test-page" + + mock_get_actions.return_value = ["mock_action"] + + mock_exporter_instance = MagicMock() + mock_exporter_instance.export_session.return_value = "https://notion.so/test-page" + mock_exporter_class.return_value = mock_exporter_instance + + response = client.post("/api/v1/session/export/notion", json={"session_id": "test-session"}) + + assert response.status_code == 200 + assert response.json() == {"message": "Session exported successfully to Notion", "url": "https://notion.so/test-page"} + mock_get_actions.assert_called_once_with("test-session") + mock_exporter_instance.export_session.assert_called_once_with("test-session", "test-page", ["mock_action"]) + +@patch("api.routes.session.settings") +def test_export_session_missing_config(mock_settings): + mock_settings.NOTION_API_KEY = "" + mock_settings.NOTION_PARENT_PAGE_ID = "" + + response = client.post("/api/v1/session/export/notion", json={"session_id": "test-session"}) + assert response.status_code == 500 + assert "Notion configuration is missing" in response.json()["detail"] diff --git a/tests/integrations/test_notion_exporter.py b/tests/integrations/test_notion_exporter.py new file mode 100644 index 0000000..9a132e1 --- /dev/null +++ b/tests/integrations/test_notion_exporter.py @@ -0,0 +1,43 @@ +import pytest +from unittest.mock import MagicMock, patch +from core.integrations.notion_exporter import NotionExporter +from core.hybrid.action_logger import ActionRecord +from datetime import datetime + +@pytest.fixture +def mock_notion_client(): + with patch("core.integrations.notion_exporter.Client") as mock_client: + mock_instance = MagicMock() + mock_client.return_value = mock_instance + yield mock_instance + +def test_export_session(mock_notion_client): + mock_notion_client.pages.create.return_value = {"url": "https://notion.so/test-page"} + + exporter = NotionExporter("test-key") + actions = [ + ActionRecord( + id="1", session_id="test-session", timestamp=datetime.now(), + type="click", description="test click", domain="digital", + was_guided=True, guidance_confidence=0.95 + ) + ] + + url = exporter.export_session("test-session", "test-page-id", actions) + + assert url == "https://notion.so/test-page" + mock_notion_client.pages.create.assert_called_once() + + call_kwargs = mock_notion_client.pages.create.call_args.kwargs + assert call_kwargs["parent"] == {"page_id": "test-page-id"} + assert call_kwargs["properties"]["title"][0]["text"]["content"] == "Execra Session: test-session" + assert len(call_kwargs["children"]) == 1 + + table_rows = call_kwargs["children"][0]["table"]["children"] + assert len(table_rows) == 2 + + # Check data mapping in row + data_row = table_rows[1]["table_row"]["cells"] + assert data_row[1][0]["text"]["content"] == "click" + assert data_row[4][0]["text"]["content"] == "Yes" + assert data_row[5][0]["text"]["content"] == "0.95"