Merge remote-tracking branch 'upstream/dev' into feat/composio

This commit is contained in:
Anish Sarkar 2026-01-23 14:35:17 +05:30
commit fae52345f8
65 changed files with 3291 additions and 153 deletions

View file

@ -281,8 +281,10 @@ async def create_comment(
detail="You don't have permission to create comments in this search space",
)
thread = message.thread
comment = ChatComment(
message_id=message_id,
thread_id=thread.id, # Denormalized for efficient Electric subscriptions
author_id=user.id,
content=content,
)
@ -299,7 +301,6 @@ async def create_comment(
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
# Create notifications for mentioned users (excluding author)
thread = message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
@ -393,8 +394,10 @@ async def create_reply(
detail="You don't have permission to create comments in this search space",
)
thread = parent_comment.message.thread
reply = ChatComment(
message_id=parent_comment.message_id,
thread_id=thread.id, # Denormalized for efficient Electric subscriptions
parent_id=comment_id,
author_id=user.id,
content=content,
@ -412,7 +415,6 @@ async def create_reply(
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
# Create notifications for mentioned users (excluding author)
thread = parent_comment.message.thread
author_name = user.display_name or user.email
content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():

View file

@ -0,0 +1,65 @@
"""
Service layer for chat session state (live collaboration).
"""
from datetime import UTC, datetime
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db import ChatSessionState
async def get_session_state(
session: AsyncSession,
thread_id: int,
) -> ChatSessionState | None:
"""Get the current session state for a thread."""
result = await session.execute(
select(ChatSessionState)
.options(selectinload(ChatSessionState.ai_responding_to_user))
.filter(ChatSessionState.thread_id == thread_id)
)
return result.scalar_one_or_none()
async def set_ai_responding(
session: AsyncSession,
thread_id: int,
user_id: UUID,
) -> ChatSessionState:
"""Mark AI as responding to a specific user. Uses upsert for atomicity."""
now = datetime.now(UTC)
upsert_query = insert(ChatSessionState).values(
thread_id=thread_id,
ai_responding_to_user_id=user_id,
updated_at=now,
)
upsert_query = upsert_query.on_conflict_do_update(
index_elements=["thread_id"],
set_={
"ai_responding_to_user_id": user_id,
"updated_at": now,
},
)
await session.execute(upsert_query)
await session.commit()
return await get_session_state(session, thread_id)
async def clear_ai_responding(
session: AsyncSession,
thread_id: int,
) -> ChatSessionState | None:
"""Clear AI responding state when response is complete."""
state = await get_session_state(session, thread_id)
if state:
state.ai_responding_to_user_id = None
state.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(state)
return state

View file

@ -2780,3 +2780,94 @@ class ConnectorService:
}
return result_object, circleback_docs
async def search_obsidian(
self,
user_query: str,
search_space_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> tuple:
"""
Search for Obsidian vault notes and return both the source information and langchain documents.
Uses combined chunk-level and document-level hybrid search with RRF fusion.
Args:
user_query: The user's query
search_space_id: The search space ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
Returns:
tuple: (sources_info, langchain_documents)
"""
obsidian_docs = await self._combined_rrf_search(
query_text=user_query,
search_space_id=search_space_id,
document_type="OBSIDIAN_CONNECTOR",
top_k=top_k,
start_date=start_date,
end_date=end_date,
)
# Early return if no results
if not obsidian_docs:
return {
"id": 53,
"name": "Obsidian Vault",
"type": "OBSIDIAN_CONNECTOR",
"sources": [],
}, []
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
return doc_info.get("title", "Untitled Note")
def _url_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
# Obsidian URL format: obsidian://vault_name/path
return doc_info.get("url", "")
def _description_fn(
chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
) -> str:
description = self._chunk_preview(chunk.get("content", ""), limit=200)
info_parts = []
vault_name = metadata.get("vault_name")
tags = metadata.get("tags", [])
if vault_name:
info_parts.append(f"Vault: {vault_name}")
if tags and isinstance(tags, list) and len(tags) > 0:
info_parts.append(f"Tags: {', '.join(tags[:3])}")
if info_parts:
description = (description + " | " + " | ".join(info_parts)).strip(" |")
return description
def _extra_fields_fn(
_chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
) -> dict[str, Any]:
return {
"vault_name": metadata.get("vault_name", ""),
"file_path": metadata.get("file_path", ""),
"tags": metadata.get("tags", []),
"outgoing_links": metadata.get("outgoing_links", []),
}
sources_list = self._build_chunk_sources_from_documents(
obsidian_docs,
title_fn=_title_fn,
url_fn=_url_fn,
description_fn=_description_fn,
extra_fields_fn=_extra_fields_fn,
)
# Create result object
result_object = {
"id": 53,
"name": "Obsidian Vault",
"type": "OBSIDIAN_CONNECTOR",
"sources": sources_list,
}
return result_object, obsidian_docs

View file

@ -623,6 +623,28 @@ class MentionNotificationHandler(BaseNotificationHandler):
def __init__(self):
super().__init__("new_mention")
async def find_notification_by_mention(
self,
session: AsyncSession,
mention_id: int,
) -> Notification | None:
"""
Find an existing notification by mention ID.
Args:
session: Database session
mention_id: The mention ID to search for
Returns:
Notification if found, None otherwise
"""
query = select(Notification).where(
Notification.type == self.notification_type,
Notification.notification_metadata["mention_id"].astext == str(mention_id),
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def notify_new_mention(
self,
session: AsyncSession,
@ -641,11 +663,12 @@ class MentionNotificationHandler(BaseNotificationHandler):
) -> Notification:
"""
Create notification when a user is @mentioned in a comment.
Uses mention_id for idempotency to prevent duplicate notifications.
Args:
session: Database session
mentioned_user_id: User who was mentioned
mention_id: ID of the mention record
mention_id: ID of the mention record (used for idempotency)
comment_id: ID of the comment containing the mention
message_id: ID of the message being commented on
thread_id: ID of the chat thread
@ -658,8 +681,16 @@ class MentionNotificationHandler(BaseNotificationHandler):
search_space_id: Search space ID
Returns:
Notification: The created notification
Notification: The created or existing notification
"""
# Check if notification already exists for this mention (idempotency)
existing = await self.find_notification_by_mention(session, mention_id)
if existing:
logger.info(
f"Notification already exists for mention {mention_id}, returning existing"
)
return existing
title = f"{author_name} mentioned you"
message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
@ -676,21 +707,37 @@ class MentionNotificationHandler(BaseNotificationHandler):
"content_preview": content_preview[:200],
}
notification = Notification(
user_id=mentioned_user_id,
search_space_id=search_space_id,
type=self.notification_type,
title=title,
message=message,
notification_metadata=metadata,
)
session.add(notification)
await session.commit()
await session.refresh(notification)
logger.info(
f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
)
return notification
try:
notification = Notification(
user_id=mentioned_user_id,
search_space_id=search_space_id,
type=self.notification_type,
title=title,
message=message,
notification_metadata=metadata,
)
session.add(notification)
await session.commit()
await session.refresh(notification)
logger.info(
f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
)
return notification
except Exception as e:
# Handle race condition - if duplicate key error, try to fetch existing
await session.rollback()
if (
"duplicate key" in str(e).lower()
or "unique constraint" in str(e).lower()
):
logger.warning(
f"Duplicate notification detected for mention {mention_id}, fetching existing"
)
existing = await self.find_notification_by_mention(session, mention_id)
if existing:
return existing
# Re-raise if not a duplicate key error or couldn't find existing
raise
class NotificationService: