From 5642f53e11f28dd84848a3812cd155e6b234b020 Mon Sep 17 00:00:00 2001 From: markov Date: Fri, 27 Feb 2026 08:33:39 +0100 Subject: [PATCH] Structured system messages: actor + mentions as objects - Message model: added actor_id FK (who initiated the action) - MessageOut: mentions as MemberBrief[], actor as MemberBrief - _system_message: accepts mentioned_members list, stores actor_id - WS filtering: checks mentions array (not just text content) - broadcast_message: handles both string and dict mentions --- src/tracker/api/converters.py | 12 +++++++++- src/tracker/api/schemas.py | 3 ++- src/tracker/api/tasks.py | 43 +++++++++++++++++++++++++---------- src/tracker/models/chat.py | 6 ++++- src/tracker/ws/handler.py | 5 +++- src/tracker/ws/manager.py | 15 +++++++++--- 6 files changed, 65 insertions(+), 19 deletions(-) diff --git a/src/tracker/api/converters.py b/src/tracker/api/converters.py index b718039..1bc6a60 100644 --- a/src/tracker/api/converters.py +++ b/src/tracker/api/converters.py @@ -14,6 +14,15 @@ from .schemas import ( ) +def _resolve_mentions(m: Message) -> list[MemberBrief]: + """Convert stored mention slugs to MemberBrief objects. + Uses pre-loaded relationships if available, otherwise returns slug-only briefs.""" + if not m.mentions: + return [] + # For now: return slug-only briefs (id will be empty until we store mention IDs) + return [MemberBrief(id="", slug=slug, name=slug) for slug in m.mentions] + + def member_brief(m: Member | None) -> MemberBrief | None: if not m: return None @@ -75,7 +84,8 @@ def message_out(m: Message) -> MessageOut: author=member_brief(m.author) if m.author else None, content=m.content, thinking=m.thinking, - mentions=m.mentions or [], + mentions=_resolve_mentions(m), + actor=member_brief(m.actor) if hasattr(m, 'actor') and m.actor else None, voice_url=m.voice_url, attachments=[attachment_out(a) for a in (m.attachments or [])], created_at=m.created_at.isoformat() if m.created_at else "", diff --git a/src/tracker/api/schemas.py b/src/tracker/api/schemas.py index ae5714f..0163079 100644 --- a/src/tracker/api/schemas.py +++ b/src/tracker/api/schemas.py @@ -61,7 +61,8 @@ class MessageOut(BaseModel): author: MemberBrief | None = None content: str thinking: str | None = None - mentions: list[str] = [] + mentions: list[MemberBrief] = [] + actor: MemberBrief | None = None voice_url: str | None = None attachments: list[AttachmentOut] = [] created_at: str diff --git a/src/tracker/api/tasks.py b/src/tracker/api/tasks.py index 1b4eb84..32cf368 100644 --- a/src/tracker/api/tasks.py +++ b/src/tracker/api/tasks.py @@ -16,7 +16,7 @@ from ..enums import ( ) from ..models import Task, Step, Project, Member, Message, Chat, TaskAction from .auth import get_current_member -from .schemas import TaskOut, MessageOut +from .schemas import TaskOut, MessageOut, MemberBrief from .converters import task_out, message_out, member_brief router = APIRouter(tags=["tasks"]) @@ -94,15 +94,24 @@ async def _system_message( task_text: str | None = None, project_slug: str = "", actor: Member | None = None, + mentioned_members: list[Member] | None = None, ): """Create system messages: one in project chat + one in task comments. - Uses MessageOut schema for WS broadcasts.""" + + actor: who initiated the action (stored as actor_id) + mentioned_members: members referenced in the message (stored in mentions array) + """ from ..ws.manager import manager prefix = project_slug[:2].upper() if project_slug else "XX" key = f"{prefix}-{task.number}" task_text = task_text or chat_text actor_slug = actor.slug if actor else "system" + actor_brief = MemberBrief(id=str(actor.id), slug=actor.slug, name=actor.name) if actor else None + + # Mentioned members as slugs (DB storage) and briefs (broadcast) + mention_slugs = [m.slug for m in (mentioned_members or [])] + mention_briefs = [MemberBrief(id=str(m.id), slug=m.slug, name=m.name) for m in (mentioned_members or [])] chat_id = await _get_project_chat_id(db, task.project_id) @@ -111,8 +120,9 @@ async def _system_message( task_id=task.id, author_type=AuthorType.SYSTEM, author_id=None, + actor_id=actor.id if actor else None, content=task_text, - mentions=[], + mentions=mention_slugs, # still stored as slugs in DB for compat ) db.add(task_msg) @@ -123,26 +133,29 @@ async def _system_message( chat_id=chat_id, author_type=AuthorType.SYSTEM, author_id=None, + actor_id=actor.id if actor else None, content=chat_text, - mentions=[], + mentions=mention_slugs, ) db.add(chat_msg) await db.flush() - # Build MessageOut for task comment broadcast (use broadcast_message, not broadcast_task_event) + now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat() + + # Build MessageOut for task comment broadcast task_msg_out = MessageOut( id=str(task_msg.id), task_id=str(task.id), author_type=AuthorType.SYSTEM, author_id=None, author=None, + actor=actor_brief, content=task_text, - mentions=[], + mentions=mention_briefs, attachments=[], - created_at=task_msg.created_at.isoformat() if task_msg.created_at else datetime.datetime.now(datetime.timezone.utc).isoformat(), + created_at=task_msg.created_at.isoformat() if task_msg.created_at else now_iso, ) - # Broadcast task comment via broadcast_message (applies agent filtering: @mention check) project_id = str(task.project_id) await manager.broadcast_message( project_id, @@ -158,12 +171,13 @@ async def _system_message( author_type=AuthorType.SYSTEM, author_id=None, author=None, + actor=actor_brief, content=chat_text, - mentions=[], + mentions=mention_briefs, attachments=[], - created_at=chat_msg.created_at.isoformat() if chat_msg.created_at else datetime.datetime.now(datetime.timezone.utc).isoformat(), + created_at=chat_msg.created_at.isoformat() if chat_msg.created_at else now_iso, ) - await manager.broadcast_message(str(task.project_id), chat_msg_out.model_dump(), author_slug="system") + await manager.broadcast_message(project_id, chat_msg_out.model_dump(), author_slug="system") async def _get_task(task_id: str, db: AsyncSession) -> Task: @@ -284,11 +298,14 @@ async def create_task( # System message chat_text = f"{key} создана: {task.title}" task_text = f"Задача создана: {task.title}" + mentioned = [] if task_full.assignee: chat_text += f" → @{task_full.assignee.slug}" task_text += f". Назначена на @{task_full.assignee.slug}" + mentioned.append(task_full.assignee) await _system_message(db, task_full, chat_text=chat_text, task_text=task_text, - project_slug=project.slug, actor=current_member) + project_slug=project.slug, actor=current_member, + mentioned_members=mentioned) await db.commit() return task_out(task_full, project.slug) @@ -357,6 +374,7 @@ async def update_task( chat_text=f"{key}: назначена на @{new_assignee.slug}", task_text=f"{who} назначил задачу на @{new_assignee.slug} ({now_str})", project_slug=slug, actor=current_member, + mentioned_members=[new_assignee], ) if new_assignee_id not in (task.watcher_ids or []): task.watcher_ids = (task.watcher_ids or []) + [new_assignee_id] @@ -518,6 +536,7 @@ async def assign_task( chat_text=f"{key}: назначена на @{assignee.slug}", task_text=f"@{current_member.slug} назначил задачу на @{assignee.slug}", project_slug=proj_slug, actor=current_member, + mentioned_members=[assignee], ) await db.commit() diff --git a/src/tracker/models/chat.py b/src/tracker/models/chat.py index 3e020ea..d9744f1 100644 --- a/src/tracker/models/chat.py +++ b/src/tracker/models/chat.py @@ -44,6 +44,9 @@ class Message(Base): author_type: Mapped[str] = mapped_column(String(20), nullable=False) # human | agent | system author_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("members.id"), nullable=True) + # Actor — who initiated the action (for system messages: who created/moved/assigned the task) + actor_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("members.id"), nullable=True) + # Content content: Mapped[str] = mapped_column(Text, nullable=False) thinking: Mapped[str | None] = mapped_column(Text, nullable=True) # LLM reasoning/thinking block @@ -52,7 +55,8 @@ class Message(Base): # Relationships chat: Mapped["Chat | None"] = relationship(back_populates="messages", foreign_keys=[chat_id]) - author: Mapped["Member"] = relationship() + author: Mapped["Member"] = relationship(foreign_keys=[author_id]) + actor: Mapped["Member | None"] = relationship(foreign_keys=[actor_id]) attachments: Mapped[list["Attachment"]] = relationship(back_populates="message", cascade="all, delete-orphan") replies: Mapped[list["Message"]] = relationship(back_populates="parent") parent: Mapped["Message | None"] = relationship(back_populates="replies", remote_side="Message.id") diff --git a/src/tracker/ws/handler.py b/src/tracker/ws/handler.py index ad25a65..84708cf 100644 --- a/src/tracker/ws/handler.py +++ b/src/tracker/ws/handler.py @@ -31,6 +31,8 @@ def _to_member_brief(member: Member) -> MemberBrief: def _to_message_out(msg: Message, author: Member | None = None) -> MessageOut: + # Convert mention slugs to MemberBrief (slug-only for WS handler; full resolution in REST) + mention_briefs = [MemberBrief(id="", slug=s, name=s) for s in (msg.mentions or [])] return MessageOut( id=str(msg.id), chat_id=str(msg.chat_id) if msg.chat_id else None, @@ -41,7 +43,8 @@ def _to_message_out(msg: Message, author: Member | None = None) -> MessageOut: author=_to_member_brief(author) if author else None, content=msg.content, thinking=msg.thinking, - mentions=msg.mentions or [], + mentions=mention_briefs, + actor=_to_member_brief(msg.actor) if hasattr(msg, 'actor') and msg.actor else None, voice_url=msg.voice_url, attachments=[], # WS broadcasts don't include full attachments created_at=msg.created_at.isoformat() if msg.created_at else "", diff --git a/src/tracker/ws/manager.py b/src/tracker/ws/manager.py index 9ebbd2e..cab6514 100644 --- a/src/tracker/ws/manager.py +++ b/src/tracker/ws/manager.py @@ -89,7 +89,14 @@ class ConnectionManager: - If chat_listen == "mentions": only if @slug in mentions - System messages: only if @slug mentioned in content """ - mentions = message.get("mentions", []) + raw_mentions = message.get("mentions", []) + # mentions can be list of strings (slugs) or list of dicts (MemberBrief objects) + mentions: list[str] = [] + for m in raw_mentions: + if isinstance(m, str): + mentions.append(m) + elif isinstance(m, dict): + mentions.append(m.get("slug", "")) content = message.get("content", "") author_type = message.get("author_type", "") payload = {"type": event_type or WSEventType.MESSAGE_NEW, "data": message} @@ -109,9 +116,11 @@ class ConnectionManager: continue if client.chat_listen == ListenMode.NONE: continue - # System messages: only if agent is mentioned in content + # System messages: only if agent is mentioned (by slug in mentions array, or @slug in content for compat) if author_type == AuthorType.SYSTEM: - if f"@{client.member_slug}" not in content: + mentioned_in_mentions = client.member_slug in mentions + mentioned_in_content = f"@{client.member_slug}" in content + if not mentioned_in_mentions and not mentioned_in_content: continue await self.send_to_session(session_id, payload) continue