Structured system messages: actor + mentions as objects
Some checks failed
Deploy Tracker / deploy (push) Failing after 5s
Some checks failed
Deploy Tracker / deploy (push) Failing after 5s
- 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
This commit is contained in:
parent
f20da3685d
commit
5642f53e11
@ -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:
|
def member_brief(m: Member | None) -> MemberBrief | None:
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
@ -75,7 +84,8 @@ def message_out(m: Message) -> MessageOut:
|
|||||||
author=member_brief(m.author) if m.author else None,
|
author=member_brief(m.author) if m.author else None,
|
||||||
content=m.content,
|
content=m.content,
|
||||||
thinking=m.thinking,
|
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,
|
voice_url=m.voice_url,
|
||||||
attachments=[attachment_out(a) for a in (m.attachments or [])],
|
attachments=[attachment_out(a) for a in (m.attachments or [])],
|
||||||
created_at=m.created_at.isoformat() if m.created_at else "",
|
created_at=m.created_at.isoformat() if m.created_at else "",
|
||||||
|
|||||||
@ -61,7 +61,8 @@ class MessageOut(BaseModel):
|
|||||||
author: MemberBrief | None = None
|
author: MemberBrief | None = None
|
||||||
content: str
|
content: str
|
||||||
thinking: str | None = None
|
thinking: str | None = None
|
||||||
mentions: list[str] = []
|
mentions: list[MemberBrief] = []
|
||||||
|
actor: MemberBrief | None = None
|
||||||
voice_url: str | None = None
|
voice_url: str | None = None
|
||||||
attachments: list[AttachmentOut] = []
|
attachments: list[AttachmentOut] = []
|
||||||
created_at: str
|
created_at: str
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from ..enums import (
|
|||||||
)
|
)
|
||||||
from ..models import Task, Step, Project, Member, Message, Chat, TaskAction
|
from ..models import Task, Step, Project, Member, Message, Chat, TaskAction
|
||||||
from .auth import get_current_member
|
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
|
from .converters import task_out, message_out, member_brief
|
||||||
|
|
||||||
router = APIRouter(tags=["tasks"])
|
router = APIRouter(tags=["tasks"])
|
||||||
@ -94,15 +94,24 @@ async def _system_message(
|
|||||||
task_text: str | None = None,
|
task_text: str | None = None,
|
||||||
project_slug: str = "",
|
project_slug: str = "",
|
||||||
actor: Member | None = None,
|
actor: Member | None = None,
|
||||||
|
mentioned_members: list[Member] | None = None,
|
||||||
):
|
):
|
||||||
"""Create system messages: one in project chat + one in task comments.
|
"""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
|
from ..ws.manager import manager
|
||||||
|
|
||||||
prefix = project_slug[:2].upper() if project_slug else "XX"
|
prefix = project_slug[:2].upper() if project_slug else "XX"
|
||||||
key = f"{prefix}-{task.number}"
|
key = f"{prefix}-{task.number}"
|
||||||
task_text = task_text or chat_text
|
task_text = task_text or chat_text
|
||||||
actor_slug = actor.slug if actor else "system"
|
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)
|
chat_id = await _get_project_chat_id(db, task.project_id)
|
||||||
|
|
||||||
@ -111,8 +120,9 @@ async def _system_message(
|
|||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
author_type=AuthorType.SYSTEM,
|
author_type=AuthorType.SYSTEM,
|
||||||
author_id=None,
|
author_id=None,
|
||||||
|
actor_id=actor.id if actor else None,
|
||||||
content=task_text,
|
content=task_text,
|
||||||
mentions=[],
|
mentions=mention_slugs, # still stored as slugs in DB for compat
|
||||||
)
|
)
|
||||||
db.add(task_msg)
|
db.add(task_msg)
|
||||||
|
|
||||||
@ -123,26 +133,29 @@ async def _system_message(
|
|||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
author_type=AuthorType.SYSTEM,
|
author_type=AuthorType.SYSTEM,
|
||||||
author_id=None,
|
author_id=None,
|
||||||
|
actor_id=actor.id if actor else None,
|
||||||
content=chat_text,
|
content=chat_text,
|
||||||
mentions=[],
|
mentions=mention_slugs,
|
||||||
)
|
)
|
||||||
db.add(chat_msg)
|
db.add(chat_msg)
|
||||||
|
|
||||||
await db.flush()
|
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(
|
task_msg_out = MessageOut(
|
||||||
id=str(task_msg.id),
|
id=str(task_msg.id),
|
||||||
task_id=str(task.id),
|
task_id=str(task.id),
|
||||||
author_type=AuthorType.SYSTEM,
|
author_type=AuthorType.SYSTEM,
|
||||||
author_id=None,
|
author_id=None,
|
||||||
author=None,
|
author=None,
|
||||||
|
actor=actor_brief,
|
||||||
content=task_text,
|
content=task_text,
|
||||||
mentions=[],
|
mentions=mention_briefs,
|
||||||
attachments=[],
|
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)
|
project_id = str(task.project_id)
|
||||||
await manager.broadcast_message(
|
await manager.broadcast_message(
|
||||||
project_id,
|
project_id,
|
||||||
@ -158,12 +171,13 @@ async def _system_message(
|
|||||||
author_type=AuthorType.SYSTEM,
|
author_type=AuthorType.SYSTEM,
|
||||||
author_id=None,
|
author_id=None,
|
||||||
author=None,
|
author=None,
|
||||||
|
actor=actor_brief,
|
||||||
content=chat_text,
|
content=chat_text,
|
||||||
mentions=[],
|
mentions=mention_briefs,
|
||||||
attachments=[],
|
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:
|
async def _get_task(task_id: str, db: AsyncSession) -> Task:
|
||||||
@ -284,11 +298,14 @@ async def create_task(
|
|||||||
# System message
|
# System message
|
||||||
chat_text = f"{key} создана: {task.title}"
|
chat_text = f"{key} создана: {task.title}"
|
||||||
task_text = f"Задача создана: {task.title}"
|
task_text = f"Задача создана: {task.title}"
|
||||||
|
mentioned = []
|
||||||
if task_full.assignee:
|
if task_full.assignee:
|
||||||
chat_text += f" → @{task_full.assignee.slug}"
|
chat_text += f" → @{task_full.assignee.slug}"
|
||||||
task_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,
|
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()
|
await db.commit()
|
||||||
|
|
||||||
return task_out(task_full, project.slug)
|
return task_out(task_full, project.slug)
|
||||||
@ -357,6 +374,7 @@ async def update_task(
|
|||||||
chat_text=f"{key}: назначена на @{new_assignee.slug}",
|
chat_text=f"{key}: назначена на @{new_assignee.slug}",
|
||||||
task_text=f"{who} назначил задачу на @{new_assignee.slug} ({now_str})",
|
task_text=f"{who} назначил задачу на @{new_assignee.slug} ({now_str})",
|
||||||
project_slug=slug, actor=current_member,
|
project_slug=slug, actor=current_member,
|
||||||
|
mentioned_members=[new_assignee],
|
||||||
)
|
)
|
||||||
if new_assignee_id not in (task.watcher_ids or []):
|
if new_assignee_id not in (task.watcher_ids or []):
|
||||||
task.watcher_ids = (task.watcher_ids or []) + [new_assignee_id]
|
task.watcher_ids = (task.watcher_ids or []) + [new_assignee_id]
|
||||||
@ -518,6 +536,7 @@ async def assign_task(
|
|||||||
chat_text=f"{key}: назначена на @{assignee.slug}",
|
chat_text=f"{key}: назначена на @{assignee.slug}",
|
||||||
task_text=f"@{current_member.slug} назначил задачу на @{assignee.slug}",
|
task_text=f"@{current_member.slug} назначил задачу на @{assignee.slug}",
|
||||||
project_slug=proj_slug, actor=current_member,
|
project_slug=proj_slug, actor=current_member,
|
||||||
|
mentioned_members=[assignee],
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,9 @@ class Message(Base):
|
|||||||
author_type: Mapped[str] = mapped_column(String(20), nullable=False) # human | agent | system
|
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)
|
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
|
||||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
thinking: Mapped[str | None] = mapped_column(Text, nullable=True) # LLM reasoning/thinking block
|
thinking: Mapped[str | None] = mapped_column(Text, nullable=True) # LLM reasoning/thinking block
|
||||||
@ -52,7 +55,8 @@ class Message(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
chat: Mapped["Chat | None"] = relationship(back_populates="messages", foreign_keys=[chat_id])
|
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")
|
attachments: Mapped[list["Attachment"]] = relationship(back_populates="message", cascade="all, delete-orphan")
|
||||||
replies: Mapped[list["Message"]] = relationship(back_populates="parent")
|
replies: Mapped[list["Message"]] = relationship(back_populates="parent")
|
||||||
parent: Mapped["Message | None"] = relationship(back_populates="replies", remote_side="Message.id")
|
parent: Mapped["Message | None"] = relationship(back_populates="replies", remote_side="Message.id")
|
||||||
|
|||||||
@ -31,6 +31,8 @@ def _to_member_brief(member: Member) -> MemberBrief:
|
|||||||
|
|
||||||
|
|
||||||
def _to_message_out(msg: Message, author: Member | None = None) -> MessageOut:
|
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(
|
return MessageOut(
|
||||||
id=str(msg.id),
|
id=str(msg.id),
|
||||||
chat_id=str(msg.chat_id) if msg.chat_id else None,
|
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,
|
author=_to_member_brief(author) if author else None,
|
||||||
content=msg.content,
|
content=msg.content,
|
||||||
thinking=msg.thinking,
|
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,
|
voice_url=msg.voice_url,
|
||||||
attachments=[], # WS broadcasts don't include full attachments
|
attachments=[], # WS broadcasts don't include full attachments
|
||||||
created_at=msg.created_at.isoformat() if msg.created_at else "",
|
created_at=msg.created_at.isoformat() if msg.created_at else "",
|
||||||
|
|||||||
@ -89,7 +89,14 @@ class ConnectionManager:
|
|||||||
- If chat_listen == "mentions": only if @slug in mentions
|
- If chat_listen == "mentions": only if @slug in mentions
|
||||||
- System messages: only if @slug mentioned in content
|
- 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", "")
|
content = message.get("content", "")
|
||||||
author_type = message.get("author_type", "")
|
author_type = message.get("author_type", "")
|
||||||
payload = {"type": event_type or WSEventType.MESSAGE_NEW, "data": message}
|
payload = {"type": event_type or WSEventType.MESSAGE_NEW, "data": message}
|
||||||
@ -109,9 +116,11 @@ class ConnectionManager:
|
|||||||
continue
|
continue
|
||||||
if client.chat_listen == ListenMode.NONE:
|
if client.chat_listen == ListenMode.NONE:
|
||||||
continue
|
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 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
|
continue
|
||||||
await self.send_to_session(session_id, payload)
|
await self.send_to_session(session_id, payload)
|
||||||
continue
|
continue
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user