"""Whether one employee found one answer useful — recorded once, for every surface. This used to live inside the Feishu **card** handler, tangled with the code that re-renders a card after a tap. That was fine while a card was the only way to rate an answer, and it is exactly why the web page had no rating at all: the persistence was not reachable without a card to redraw. So the record and the rendering are separated here. What lives in this module is the part that must be identical on both surfaces — who may rate what, how a changed mind is stored, and when a negative verdict opens a work item. What each channel keeps is its own way of saying "got it". The reason a rating matters at all: deflection used to be counted as "tickets not filed", which measures avoidance — an employee who gave up looks exactly like one who got their answer. A verdict is the only record that tells them apart, and an unhelpful one is a work item in the knowledge-gap queue, not a terminal event. """ from __future__ import annotations import logging import uuid from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) #: Why an answer did not work, and which kind of fix that implies. The mapping #: is the useful half: "incorrect" and "outdated" both read as a bad answer to #: the employee, but one is rewritten and the other is re-sourced, and the #: knowledge-gap queue routes on the difference. REASON_CLASSIFICATIONS: dict[str, str] = { "incorrect": "knowledge_quality", "outdated": "knowledge_freshness", "incomplete": "knowledge_coverage", "still_needs_human": "handoff_needed", } async def own_message( db: AsyncSession, message_id: uuid.UUID, person: IdentityPerson, ) -> Any | None: """The message, only if it belongs to this employee's own conversation. Enforced here rather than per channel because both channels can be handed someone else's message id: a Feishu card is forwardable by default, and a web client sends whatever id it likes. Same check, same reason. """ from app.assistant.models import ( # noqa: PLC0415 AssistantConversation, AssistantMessage, ) message = await db.get(AssistantMessage, message_id) if message is None: return None conversation = await db.get(AssistantConversation, message.conversation_id) if conversation is None or str(conversation.person_id) != str(person.id): logger.info( "assistant: rating another person's message refused (person=%s)", person.id, ) return None return message async def record_verdict( db: AsyncSession, *, person: IdentityPerson, message_id: uuid.UUID, helpful: bool, trace_id: str, ) -> Any | None: """Store one employee's verdict on one answer. ``None`` if it is not theirs. Upsert on (message, person): rating the other way changes a mind already recorded rather than stacking a second vote. """ from sqlalchemy import func # noqa: PLC0415 from sqlalchemy.dialects.mysql import insert as mysql_insert # noqa: PLC0415 from app.assistant.models import ( # noqa: PLC0415 GAP_IGNORED, GAP_OPEN, VERDICT_HELPFUL, VERDICT_UNHELPFUL, AssistantMessageFeedback, AssistantMessageFeedbackEvent, ) from app.core.coordinators.assistant_support_coordinator import ( # noqa: PLC0415 record_assistant_request_verdict, ) message = await own_message(db, message_id, person) if message is None: return None verdict = VERDICT_HELPFUL if helpful else VERDICT_UNHELPFUL # A single INSERT .. ON DUPLICATE KEY UPDATE, not SELECT-then-INSERT. # Feishu re-posts a callback it considers slow and a browser double-click # does the same thing, so two requests race here routinely; # check-then-insert against uq_assistant_feedback_message_person loses that # race with a 1062 and shows the employee a failure for a verdict that WAS # recorded. The database decides, in one statement. stmt = mysql_insert(AssistantMessageFeedback).values( message_id=message_id, person_id=person.id, verdict=verdict, trace_id=trace_id, ) await db.execute( stmt.on_duplicate_key_update( verdict=stmt.inserted.verdict, # Switching from 👍 back to 👎 starts a new actionable gap. A # positive verdict makes the existing work item no longer active. gap_status=GAP_OPEN if not helpful else GAP_IGNORED, reason_code=None, classification=None, trace_id=stmt.inserted.trace_id, # Set explicitly. The model declares ``onupdate=func.now()`` and the # table has no ``ON UPDATE CURRENT_TIMESTAMP``, and SQLAlchemy does # not fold ``onupdate`` into an ON DUPLICATE KEY clause — so # changing 👍 to 👎 would leave the row claiming it was last touched # when the first verdict was cast. updated_at=func.now(), ), ) db.add( AssistantMessageFeedbackEvent( message_id=message_id, person_id=person.id, verdict=verdict, trace_id=trace_id, ) ) await record_assistant_request_verdict( db, service_request_id=message.service_request_id, helpful=helpful, ) await db.commit() return message async def record_reason( db: AsyncSession, *, person: IdentityPerson, message_id: uuid.UUID, reason: str, trace_id: str, ) -> Any | None: """Attach a structured reason to an existing negative verdict. Returns ``None`` when the reason is unknown, the message is not theirs, or there is no negative verdict to attach it to — a reason without a verdict is a vote that was never cast. """ from sqlalchemy import select # noqa: PLC0415 from app.assistant.models import ( # noqa: PLC0415 VERDICT_UNHELPFUL, AssistantMessageFeedback, AssistantMessageFeedbackEvent, ) classification = REASON_CLASSIFICATIONS.get(reason) if classification is None: return None message = await own_message(db, message_id, person) if message is None: return None existing = ( await db.execute( select(AssistantMessageFeedback).where( AssistantMessageFeedback.message_id == message_id, AssistantMessageFeedback.person_id == person.id, AssistantMessageFeedback.verdict == VERDICT_UNHELPFUL, ) ) ).scalar_one_or_none() if existing is None: return None existing.reason_code = reason existing.classification = classification existing.trace_id = trace_id db.add( AssistantMessageFeedbackEvent( message_id=message_id, person_id=person.id, verdict=VERDICT_UNHELPFUL, reason_code=reason, classification=classification, trace_id=trace_id, ) ) await db.commit() return message