"""How an offer reaches the employee — the one thing the two channels do not share. Everything upstream of this file is identical on both surfaces: the same tool, the same server-side parameter validation, the same active-employee gate, the same corroboration check, the same stored proposal with the same token and digest. What differs is the last inch — a Feishu chat gets an interactive card pushed to it, and a browser gets the offer back in the HTTP response it is already waiting on. That inch was the whole reason the web copilot had no write path at all: the propose dispatcher called ``channel.send_card`` directly, which needs an ``open_id``, which a browser does not have. So the web page shipped for a year with a model that could look nothing up and offer nothing — while the Feishu bot could do both. The seam is deliberately this narrow. Anything wider (a per-channel dispatcher, a per-channel proposal service) is a second place for the two surfaces to disagree about what an employee is allowed to submit, and that class of drift is exactly what this PR exists to remove. """ from __future__ import annotations import logging from dataclasses import dataclass, field from typing import Any, Protocol from sqlalchemy.ext.asyncio import AsyncSession from app.assistant.actions import AssistantAction from app.core.locale import Lang logger = logging.getLogger(__name__) class OfferDelivery(Protocol): """Put one confirmation offer in front of one employee.""" async def offer( self, db: AsyncSession, *, action: AssistantAction, params: dict[str, Any], digest: str, url: str, lang: Lang, proposal_id: str, ) -> bool: """Deliver the offer. ``False`` means the employee did not get it.""" ... async def notice(self, db: AsyncSession, *, text: str) -> None: """Tell the employee something the model must not be trusted to say. Used for the two failure paths — the proposal could not be stored, or the offer could not be delivered — where the deterministic wording is the whole point: a model asked to explain its own failed side effect will announce a submission that never happened. """ ... @dataclass class FeishuOfferDelivery: """The original path: an interactive card pushed into the employee's chat.""" open_id: str async def offer( self, db: AsyncSession, *, action: AssistantAction, params: dict[str, Any], digest: str, url: str, lang: Lang, proposal_id: str, ) -> bool: from app.assistant import channel, confirm_card # noqa: PLC0415 — cycle return bool( await channel.send_card( db, to=channel.ReplyTarget.dm(self.open_id), card=confirm_card.build_confirmation_card( action=action, params=params, digest=digest, url=url, lang=lang, proposal_id=proposal_id, ), idempotency_key=f"assistant-proposal-{proposal_id}", ) ) async def notice(self, db: AsyncSession, *, text: str) -> None: from app.assistant import channel # noqa: PLC0415 — cycle await channel.send_text( db, to=channel.ReplyTarget.dm(self.open_id), text=text, ) def offer_payload( *, action: AssistantAction, params: dict[str, Any], url: str, lang: Lang, proposal_id: str, expires_in_minutes: int | None = None, ) -> dict[str, Any]: """One offer, as the browser renders it. Shared by the live turn and by the thread the page rehydrates on load, so a pending offer looks the same whether the employee just asked for it or came back to the tab an hour later. The rows are the SAME rows the Feishu card shows (``action.summarise``), truncated by the same rule (``confirm_card.clip_value``). Two surfaces that summarise one proposal differently are two surfaces where an employee approved different words. """ from app.assistant.confirm_card import clip_value # noqa: PLC0415 — cycle from app.assistant.proposals import PROPOSAL_TTL_MINUTES # noqa: PLC0415 — cycle rows = [] for row in action.summarise(params, lang): localized = row.localized(lang) rows.append( { "label": str(localized["label"]), "value": clip_value(str(localized["value"])), } ) return { "proposal_id": proposal_id, "action_key": action.key, "title": action.localized_title(lang), "intent": action.localized_intent(lang), "rows": rows, # The read-only snapshot page, for the employee who wants the # untruncated value. Secondary here, exactly as on the card. "details_url": url, # The offer's REMAINING life when the caller knows it. A constant was # right for a freshly-minted offer and wrong for a rehydrated one: a # page reloaded fifty-nine minutes in drew 「剩余 60 分钟」 over a call # that was about to answer 410. "expires_in_minutes": ( PROPOSAL_TTL_MINUTES if expires_in_minutes is None else expires_in_minutes ), # Whether this offer asks a human to do it by hand. The page colours it # differently for the same reason the card does: two different outcomes # should not arrive wearing one colour. "is_ticket": action.key == "create_support_ticket", } @dataclass class WebOfferDelivery: """The browser path: the offer rides back on the response already in flight. Nothing is pushed anywhere, so this cannot fail the way a chat send can — which is why ``offer`` always returns ``True``. The honest reading of that: on this surface, "delivered" means "included in the reply the employee is about to render", and the reply and the offer arrive or fail together. """ offers: list[dict[str, Any]] = field(default_factory=list) #: Deterministic text the page must show under the reply, if any. notices: list[str] = field(default_factory=list) async def offer( self, db: AsyncSession, *, action: AssistantAction, params: dict[str, Any], digest: str, url: str, lang: Lang, proposal_id: str, ) -> bool: # ``digest`` is unused here and that is not an oversight: on this # surface the employee's own authenticated session is what authorises # the confirm call, so there is no forwardable link whose payload has to # be pinned to a parameter snapshot. It still travels inside # ``details_url`` for the read-only page, which does have that need. self.offers.append( offer_payload( action=action, params=params, url=url, lang=lang, proposal_id=proposal_id, ) ) return True async def notice(self, db: AsyncSession, *, text: str) -> None: self.notices.append(text)