"""What card is this employee replying to? The platform pushes about **3,785** notification cards a month (measured on production over 30 days: 641 inventory reminders, 296 cloud-owner 半年度 confirmations, 222 leave-expiry reminders, 125 certificate and domain expiry alerts, …). Over the same period the assistant received **98** messages, from **3 distinct people** in its entire history. Those two surfaces are not far apart — they are the same window. Cards go out via ``POST /im/v1/messages?receive_id_type=open_id`` through the same Feishu app the assistant listens on, landing in the same 1:1 chat. An employee who types a reply under a card has always been sending a message the assistant receives. What it lacked was any idea what the reply was *about*, so 「这台我早就还了」 arrived as a sentence with an unresolvable pronoun and the assistant answered like it was a new question about nothing in particular. This module closes that. Given the ``parent_id`` on an inbound reply, it finds the outbox row that card came from and turns it into a short, factual context block for the turn. Three rules it will not bend: **It resolves, it does not answer.** The block says which card and which resource; the existing tools do the looking-up, under the caller's own RBAC. Nothing here reads the resource itself, so no fact reaches the model that the employee could not have asked for directly. **Only the recipient's own cards.** The lookup is filtered by ``recipient_person_id``. A forwarded card carries a real message id, and without that filter a colleague quoting it would hand the assistant somebody else's context. **A miss is silent.** Cards sent before migration 571 have no id recorded, non-Feishu rows never had one, and people reply to ordinary messages. Every one of those is an ordinary conversation, not an error, and must degrade to exactly the behaviour that existed before. """ from __future__ import annotations import logging from typing import Any from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger(__name__) #: Hard cap on the injected block. The prompt already carries the tool #: catalogue and the conversation; a card's own title and one resource #: reference is all this needs to contribute, and an unbounded title #: from a template is untrusted length. _MAX_TITLE = 160 _MAX_BLOCK = 600 #: ``related_resource_type`` values, taken from what production ACTUALLY #: emits — not from what the constant names suggest. Measured over 90 #: days (13,011 cards), which corrected four guesses: the asset cards say #: ``asset`` and ``asset_inventory_cycle``, not ``asset_item`` / #: ``asset_inventory_item``; the leave reminder says #: ``hr_leave_reminder_batch``, not ``hr_leave_account``. #: #: Counts are the 90-day volume, kept in the comments because they are #: what justifies each entry existing: this map's whole job is to let the #: assistant name the subject of the card an employee just replied to, #: and the cards employees actually receive are the ones worth naming. #: #: A type absent from this map still produces a context block from the #: card's title. Naming is a nicety; the resolution is the feature. _RESOURCE_LABELS: dict[str, str] = { # ── The ones employees receive in volume ──────────────────────── "cmdb_cloud_access_grant": "cloud access grant", # 4807 "asset_inventory_cycle": "device inventory round they were asked to confirm", # 2351 "cmdb_cloud_scope": "cloud subscription they own", # 1934 "onboarding_process": "onboarding process", # 391 "offboarding_process": "offboarding process", # 294 "offboarding": "offboarding process", # 12 "hr_leave_reminder_batch": "annual-leave balance that is expiring", # 222 "asset": "device assigned to them", # 207 "cmdb_cloud_grant_renewal_request": "cloud access renewal request", # 169 "provisioning_task": "account provisioning task", # 162 "software_seat_allocation": "software seat", # 126 "support_ticket": "IT support ticket", # 84 "api_key_assignment": "API key", # 83 "email_group": "mail group", # 64 "cmdb_cloud_scope_request": "cloud subscription request", # 16 "approval_request": "approval request", # 11 # ── Ownership / governance cards ──────────────────────────────── "certificate": "TLS certificate that is expiring", # 173 "cmdb_server_owner_confirmation": "server ownership confirmation", # 23 "registry_approval_flow": "approval flow they own", # 11 "registry_approval_flow_round": "approval-flow ownership confirmation round", # 20 "bounty_report": "bug bounty report", # 10 # ── The mover review packet (migration 568) ───────────────────── "hr_role_change_review": "role-change review packet", # ── Admin-facing. Named for completeness, because an IT admin # replying to one of these is asking about it just as much as an # employee replying to their laptop card is. "scheduled_job": "scheduled job", # 580 "identity_person": "employee record", # 568 "identity_data_quality": "identity data-quality finding", # 27 "identity_sync_target": "identity sync target", # 12 } class CardContext: """One resolved card, ready to be described to the model.""" __slots__ = ("outbox_id", "title", "template_key", "resource_type", "resource_id", "sent_at", "url") def __init__( self, *, outbox_id: UUID, title: str, template_key: str | None, resource_type: str | None, resource_id: Any, sent_at: Any, url: str | None, ) -> None: self.outbox_id = outbox_id self.title = title self.template_key = template_key self.resource_type = resource_type self.resource_id = resource_id self.sent_at = sent_at self.url = url def as_prompt_block(self) -> str: """A short factual preamble naming what the employee replied to. Deliberately states the card and stops. It does not summarise the card's body, because the body was rendered for a human at send time and re-stating it to the model invites the model to treat stale template text as current fact — the resource may have changed since. The tools read the live state. """ lines = [ "THIS MESSAGE IS A REPLY TO A HELIX NOTIFICATION CARD.", f"Card title: {self.title[:_MAX_TITLE]}", ] if self.sent_at is not None: lines.append(f"Card sent: {self.sent_at:%Y-%m-%d}") if self.resource_type: label = _RESOURCE_LABELS.get(self.resource_type) if label and self.resource_id: lines.append( f"It is about one {label}, id {self.resource_id}.", ) elif self.resource_id: lines.append( f"It is about {self.resource_type} id {self.resource_id}.", ) lines.append( "Resolve any bare reference in their message (「这台」/「这个」" "/'it'/'this one') against that card before doing anything " "else. Look the resource up with your scoped tools — do NOT " "restate the card, and do NOT assume its details still hold.", ) return "\n".join(lines)[:_MAX_BLOCK] async def resolve_card_context( db: AsyncSession, *, person_id: UUID, parent_message_id: str | None, root_message_id: str | None = None, ) -> CardContext | None: """The card this reply is under, or ``None``. ``parent_message_id`` is tried first and ``root_message_id`` second: a reply to a reply keeps the thread root, which is still the card. Returns ``None`` for every ordinary case — a fresh question, a reply to a plain message, a card sent before migration 571 recorded ids — and the caller proceeds exactly as it did before. """ candidates = [m for m in (parent_message_id, root_message_id) if m] if not candidates: return None from app.notifications.models import NotificationOutbox row = ( await db.execute( select( NotificationOutbox.id, NotificationOutbox.title, NotificationOutbox.template_key, NotificationOutbox.related_resource_type, NotificationOutbox.related_resource_id, NotificationOutbox.created_at, NotificationOutbox.url, ) .where( NotificationOutbox.feishu_message_id.in_(candidates), # Own cards only. A forwarded card carries a real message # id, and without this a colleague quoting it would hand # the assistant a stranger's context. NotificationOutbox.recipient_person_id == person_id, ) .order_by(NotificationOutbox.created_at.desc()) .limit(1) ) ).first() if row is None: return None return CardContext( outbox_id=row[0], title=str(row[1] or ""), template_key=row[2], resource_type=row[3], resource_id=row[4], sent_at=row[5], url=row[6], ) __all__ = ["CardContext", "resolve_card_context"]