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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion backend/routers/developer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import database.action_items as action_items_db
import database.goals as goals_db
import database.users as users_db
import database.folders as folders_db

from models.memories import MemoryCategory, Memory, MemoryDB
from models.conversation import (
Expand Down Expand Up @@ -642,6 +643,8 @@ class Conversation(BaseModel):
source: Optional[str] = None
transcript_segments: Optional[List[SimpleTranscriptSegment]] = None
geolocation: Optional[Geolocation] = None
folder_id: Optional[str] = None
folder_name: Optional[str] = None


class CreateConversationRequest(BaseModel):
Expand Down Expand Up @@ -728,6 +731,26 @@ def _add_speaker_names_to_segments(uid, conversations: list):
seg['speaker_name'] = f"Speaker {seg.get('speaker_id', 0)}"


def _add_folder_names_to_conversations(uid, conversations: list):
"""Add folder_name to conversations based on folder_id mappings."""
folder_ids = set()
for conv in conversations:
if conv.get('folder_id'):
folder_ids.add(conv['folder_id'])

if not folder_ids:
for conv in conversations:
conv['folder_name'] = None
return

all_folders = folders_db.get_folders(uid)
folder_map = {f['id']: f['name'] for f in all_folders}

for conv in conversations:
folder_id = conv.get('folder_id')
conv['folder_name'] = folder_map.get(folder_id) if folder_id else None


@router.get("/v1/dev/user/conversations", response_model=List[Conversation], tags=["developer"])
def get_conversations(
start_date: Optional[datetime] = None,
Expand Down Expand Up @@ -769,6 +792,8 @@ def get_conversations(
else:
_add_speaker_names_to_segments(uid, unlocked_conversations)

_add_folder_names_to_conversations(uid, unlocked_conversations)

return unlocked_conversations


Expand Down Expand Up @@ -876,6 +901,8 @@ def get_conversation_endpoint(
else:
_add_speaker_names_to_segments(uid, [conversation])

_add_folder_names_to_conversations(uid, [conversation])

return conversation


Expand Down Expand Up @@ -1062,7 +1089,10 @@ def update_conversation_endpoint(
else:
conversations_db.update_conversation(uid, conversation_id, {'discarded': False})

return conversations_db.get_conversation(uid, conversation_id)
conversation = conversations_db.get_conversation(uid, conversation_id)
if conversation:
_add_folder_names_to_conversations(uid, [conversation])
return conversation
Comment thread
syou6162 marked this conversation as resolved.


# ******************************************************
Expand Down
253 changes: 253 additions & 0 deletions backend/tests/unit/test_folder_name_enrichment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import os
import sys
import types
from unittest.mock import MagicMock

os.environ.setdefault(
"ENCRYPTION_SECRET",
"omi_ZwB2ZNqB2HHpMK6wStk7sTpavJiPTFg7gXUHnc4tFABPU6pZ2c2DKgehtfgi4RZv",
)


def _stub_module(name):
if name not in sys.modules:
mod = types.ModuleType(name)
sys.modules[name] = mod
return sys.modules[name]


# ---------------------------------------------------------------------------
# Stub database package and submodules
# ---------------------------------------------------------------------------
database_mod = _stub_module("database")
if not hasattr(database_mod, '__path__'):
database_mod.__path__ = []
for sub in [
"_client",
"redis_db",
"memories",
"conversations",
"users",
"folders",
"action_items",
"goals",
"dev_api_key",
"notifications",
"chat",
"daily_summaries",
"apps",
"llm_usage",
"cache",
"tasks",
"trends",
"calendar_meetings",
"vector_db",
"knowledge_graph",
"mem_db",
]:
mod = _stub_module(f"database.{sub}")
setattr(database_mod, sub, mod)

# Stub database._client attributes
client_mod = sys.modules["database._client"]
client_mod.db = MagicMock()
client_mod.document_id_from_seed = MagicMock(return_value="mock-id")

# Stub redis_db attributes
redis_mod = sys.modules["database.redis_db"]
redis_mod.r = MagicMock()
for attr in [
"get_user_webhook_db",
"user_webhook_status_db",
"disable_user_webhook_db",
"enable_user_webhook_db",
"set_user_webhook_db",
]:
setattr(redis_mod, attr, MagicMock())

# Stub database.users
users_mod = sys.modules["database.users"]
users_mod.get_user_profile = MagicMock(return_value={"name": "TestUser"})
users_mod.get_people_by_ids = MagicMock(return_value=[])

# Stub database.folders with controllable mocks
folders_mod = sys.modules["database.folders"]
_mock_get_folders = MagicMock(return_value=[])
_mock_get_folder = MagicMock(return_value=None)
folders_mod.get_folders = _mock_get_folders
folders_mod.get_folder = _mock_get_folder

# ---------------------------------------------------------------------------
# Stub firebase_admin
# ---------------------------------------------------------------------------
_stub_module("firebase_admin")
_stub_module("firebase_admin.auth")
sys.modules["firebase_admin"].auth = sys.modules["firebase_admin.auth"]

# ---------------------------------------------------------------------------
# Stub utils modules that import heavy dependencies
# ---------------------------------------------------------------------------
_stub_module("utils.apps")
sys.modules["utils.apps"].update_personas_async = MagicMock()

_stub_module("utils.notifications")
sys.modules["utils.notifications"].send_notification = MagicMock()
sys.modules["utils.notifications"].send_action_item_data_message = MagicMock()

_stub_module("utils.scopes")
sys.modules["utils.scopes"].AVAILABLE_SCOPES = {}
sys.modules["utils.scopes"].validate_scopes = MagicMock()

_stub_module("utils.conversations")
_stub_module("utils.conversations.process_conversation")
sys.modules["utils.conversations.process_conversation"].process_conversation = MagicMock()

_stub_module("utils.conversations.location")
sys.modules["utils.conversations.location"].get_google_maps_location = MagicMock()

_stub_module("utils.llm")
_stub_module("utils.llm.memories")
sys.modules["utils.llm.memories"].identify_category_for_memory = MagicMock()

_stub_module("dependencies")
sys.modules["dependencies"].get_uid_from_dev_api_key = MagicMock()
sys.modules["dependencies"].get_current_user_id = MagicMock()
sys.modules["dependencies"].get_uid_with_conversations_read = MagicMock()
sys.modules["dependencies"].get_uid_with_conversations_write = MagicMock()
sys.modules["dependencies"].get_uid_with_memories_read = MagicMock()
sys.modules["dependencies"].get_uid_with_memories_write = MagicMock()
sys.modules["dependencies"].get_uid_with_action_items_read = MagicMock()
sys.modules["dependencies"].get_uid_with_action_items_write = MagicMock()
sys.modules["dependencies"].get_uid_with_goals_read = MagicMock()
sys.modules["dependencies"].get_uid_with_goals_write = MagicMock()

# ---------------------------------------------------------------------------
# Now import the actual functions under test
# ---------------------------------------------------------------------------
from routers.developer import _add_folder_names_to_conversations
from utils.webhooks import _add_folder_name_to_payload

# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestAddFolderNamesToConversations:
"""Tests for _add_folder_names_to_conversations in developer API."""

def setup_method(self):
_mock_get_folders.reset_mock()

def test_conversations_with_folder_id_get_folder_name(self):
_mock_get_folders.return_value = [
{'id': 'folder1', 'name': 'Work'},
{'id': 'folder2', 'name': 'Personal'},
]
conversations = [
{'id': 'conv1', 'folder_id': 'folder1'},
{'id': 'conv2', 'folder_id': 'folder2'},
]
_add_folder_names_to_conversations('uid1', conversations)

assert conversations[0]['folder_name'] == 'Work'
assert conversations[1]['folder_name'] == 'Personal'
_mock_get_folders.assert_called_once_with('uid1')

def test_conversations_without_folder_id_get_none(self):
conversations = [
{'id': 'conv1', 'folder_id': None},
{'id': 'conv2'},
]
_add_folder_names_to_conversations('uid1', conversations)

assert conversations[0]['folder_name'] is None
assert conversations[1]['folder_name'] is None
_mock_get_folders.assert_not_called()

def test_folder_id_not_found_in_db_returns_none(self):
_mock_get_folders.return_value = [
{'id': 'folder1', 'name': 'Work'},
]
conversations = [
{'id': 'conv1', 'folder_id': 'deleted_folder'},
]
_add_folder_names_to_conversations('uid1', conversations)

assert conversations[0]['folder_name'] is None

def test_mixed_conversations_with_and_without_folder_id(self):
_mock_get_folders.return_value = [
{'id': 'folder1', 'name': 'Work'},
]
conversations = [
{'id': 'conv1', 'folder_id': 'folder1'},
{'id': 'conv2', 'folder_id': None},
{'id': 'conv3'},
]
_add_folder_names_to_conversations('uid1', conversations)

assert conversations[0]['folder_name'] == 'Work'
assert conversations[1]['folder_name'] is None
assert conversations[2]['folder_name'] is None

def test_empty_conversations_list(self):
conversations = []
_add_folder_names_to_conversations('uid1', conversations)

assert conversations == []
_mock_get_folders.assert_not_called()

def test_batch_fetches_all_folders_once(self):
"""Verify N+1 avoidance: get_folders is called only once regardless of conversation count."""
_mock_get_folders.return_value = [
{'id': 'f1', 'name': 'Work'},
{'id': 'f2', 'name': 'Personal'},
]
conversations = [
{'id': 'conv1', 'folder_id': 'f1'},
{'id': 'conv2', 'folder_id': 'f2'},
{'id': 'conv3', 'folder_id': 'f1'},
]
_add_folder_names_to_conversations('uid1', conversations)

assert _mock_get_folders.call_count == 1


class TestAddFolderNameToPayload:
"""Tests for _add_folder_name_to_payload in webhook."""

def setup_method(self):
_mock_get_folder.reset_mock()

def test_payload_with_folder_id_gets_folder_name(self):
_mock_get_folder.return_value = {'id': 'folder1', 'name': 'Work'}
payload = {'folder_id': 'folder1'}

_add_folder_name_to_payload('uid1', payload)

assert payload['folder_name'] == 'Work'
_mock_get_folder.assert_called_once_with('uid1', 'folder1')

def test_payload_without_folder_id_gets_none(self):
payload = {'folder_id': None}

_add_folder_name_to_payload('uid1', payload)

assert payload['folder_name'] is None
_mock_get_folder.assert_not_called()

def test_payload_missing_folder_id_key_gets_none(self):
payload = {}

_add_folder_name_to_payload('uid1', payload)

assert payload['folder_name'] is None
_mock_get_folder.assert_not_called()

def test_folder_not_found_in_db_returns_none(self):
_mock_get_folder.return_value = None
payload = {'folder_id': 'deleted_folder'}

_add_folder_name_to_payload('uid1', payload)

assert payload['folder_name'] is None
12 changes: 12 additions & 0 deletions backend/utils/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from models.users import WebhookType
import database.notifications as notification_db
import database.users as users_db
import database.folders as folders_db
from utils.notifications import send_notification
import logging

Expand Down Expand Up @@ -59,6 +60,16 @@ def _add_speaker_names_to_payload(uid, payload: dict):
seg['speaker_name'] = f"Speaker {seg.get('speaker_id', 0)}"


def _add_folder_name_to_payload(uid, payload: dict):
"""Add folder_name to webhook payload based on folder_id."""
folder_id = payload.get('folder_id')
if folder_id:
folder = folders_db.get_folder(uid, folder_id)
payload['folder_name'] = folder['name'] if folder else None
else:
payload['folder_name'] = None


def conversation_created_webhook(uid, memory: Conversation):
toggled = user_webhook_status_db(uid, WebhookType.memory_created)

Expand All @@ -70,6 +81,7 @@ def conversation_created_webhook(uid, memory: Conversation):
try:
payload = memory.as_dict_cleaned_dates()
_add_speaker_names_to_payload(uid, payload)
_add_folder_name_to_payload(uid, payload)
payload = _json_serialize_datetime(payload)
response = requests.post(
webhook_url,
Expand Down
6 changes: 6 additions & 0 deletions docs/doc/developer/api/conversations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ description: "Create and retrieve conversations via the Developer API"
],
"events": []
},
"folder_id": "folder_uuid",
"folder_name": "Work",
"geolocation": {
"latitude": 37.7749295,
"longitude": -122.4194155,
Expand Down Expand Up @@ -152,6 +154,8 @@ description: "Create and retrieve conversations via the Developer API"
"end": 5.8
}
],
"folder_id": "folder_uuid",
"folder_name": "Work",
"geolocation": {
"latitude": 37.7749295,
"longitude": -122.4194155,
Expand All @@ -174,6 +178,8 @@ description: "Create and retrieve conversations via the Developer API"
| `source` | string | Source device/app |
| `structured` | object | AI-generated structured data |
| `transcript_segments` | array | Transcript segments (if `include_transcript=true`) |
| `folder_id` | string | Folder ID the conversation belongs to (optional) |
| `folder_name` | string | Folder name, resolved from the user's folder settings. Null if no folder is assigned. (optional) |
| `geolocation` | object | Location where conversation occurred (if available) |
</Accordion>
<Accordion title="Transcript Segment Fields" icon="waveform-lines">
Expand Down
4 changes: 3 additions & 1 deletion docs/doc/developer/apps/Integrations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ Your endpoint receives a POST request with the memory object:
"events": []
},
"apps_response": [],
"discarded": false
"discarded": false,
"folder_id": "folder_uuid",
"folder_name": "Work"
}
```

Expand Down
Loading