From 0e39fbe91e2a42ab88ab6d5f0b5e1deeee119aa9 Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Wed, 16 Sep 2026 20:43:32 -0600 Subject: [PATCH] =?UTF-8?q?feat(feedback):=20implicit=20signals=20?= =?UTF-8?q?=E2=80=94=20copy=20and=20continue=20(response-feedback=20PR-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §10: denser than thumbs, no UI cost. Copy and Continue in the message actions rail POST a kind code to /sessions/{id}/messages/{messageId}/signals (fire-and-forget, once per message per kind per page load). Rows share the F# family under their own key — F#{session}#{message}#{kind} — with signal="implicit", kind, and an ADDed count, so they never collide with the thumb and every thumb reader keeps filtering them out. The profile reports feedback.implicit as messages touched per kind, on its own line: explicit and implicit are never summed. Edit-and-resend has no SPA affordance to hook; abandonment is deferred by design (spec §13). Spec status and §13 updated. Co-Authored-By: Claude Fable 5.1 --- .../src/apis/app_api/admin/costs/models.py | 12 +++++ .../src/apis/app_api/admin/costs/service.py | 15 +++++- backend/src/apis/app_api/messages/models.py | 18 +++++++ backend/src/apis/app_api/sessions/routes.py | 32 ++++++++++++ .../shared/observability/content_policy.py | 2 + backend/src/apis/shared/sessions/feedback.py | 51 +++++++++++++++++-- backend/src/apis/shared/sessions/models.py | 18 +++++++ .../admin/costs/test_drilldown_routes.py | 2 +- .../costs/test_session_profile_service.py | 17 +++++++ .../app_api/test_message_feedback_routes.py | 14 ++++- backend/tests/shared/test_message_feedback.py | 29 +++++++++++ docs/specs/response-feedback.md | 22 ++++++-- .../admin/costs/models/admin-cost.models.ts | 2 + .../costs/pages/session-cost-anatomy.page.ts | 10 ++++ .../costs/pages/session-profile.util.spec.ts | 8 +++ .../admin/costs/pages/session-profile.util.ts | 10 ++++ .../message-actions.component.spec.ts | 14 +++++ .../components/message-actions.component.ts | 14 ++++- .../session/services/models/message.model.ts | 3 ++ .../session/message-feedback.service.spec.ts | 22 ++++++++ .../session/message-feedback.service.ts | 25 ++++++++- 21 files changed, 324 insertions(+), 16 deletions(-) diff --git a/backend/src/apis/app_api/admin/costs/models.py b/backend/src/apis/app_api/admin/costs/models.py index 98084046..637d9a58 100644 --- a/backend/src/apis/app_api/admin/costs/models.py +++ b/backend/src/apis/app_api/admin/costs/models.py @@ -487,6 +487,16 @@ class FeedbackByTurnClass(BaseModel): none: FeedbackCounts = Field(default_factory=FeedbackCounts) +class ImplicitSignalCounts(BaseModel): + """Implicit signals (spec §10) as *messages touched* per kind — a message + copied three times counts once here. Kept apart from the thumbs; the two + have different base rates and are never summed.""" + model_config = ConfigDict(populate_by_name=True) + + copied: int = 0 + continued: int = 0 + + class FeedbackProfile(BaseModel): """The outcome signal joined to the session's cost rows. ``byTurnClass`` is ``None`` when no cost row carries the turn-class fields (they arrive @@ -505,6 +515,8 @@ class FeedbackProfile(BaseModel): # a cost row to price. retried: int = 0 rework_usd: Optional[float] = Field(None, alias="reworkUsd") + # Implicit signals, or None when the session has none. + implicit: Optional[ImplicitSignalCounts] = None class DataCoverage(BaseModel): diff --git a/backend/src/apis/app_api/admin/costs/service.py b/backend/src/apis/app_api/admin/costs/service.py index 2fee2597..8c9930e3 100644 --- a/backend/src/apis/app_api/admin/costs/service.py +++ b/backend/src/apis/app_api/admin/costs/service.py @@ -29,6 +29,7 @@ FeedbackByTurnClass, FeedbackCounts, FeedbackProfile, + ImplicitSignalCounts, FingerprintChanges, SessionDiagnosis, SessionProfile, @@ -126,6 +127,7 @@ def _join_feedback( any_turn_class = any(_turn_class(r) is not None for r in records) buckets = FeedbackByTurnClass() if any_turn_class else None profile = FeedbackProfile() + implicit_messages: Dict[str, set] = {"copy": set(), "continue": set()} # Every cost row per assistant message index, for pricing rework. cost_by_message: Dict[int, float] = {} for record in records: @@ -134,9 +136,13 @@ def _join_feedback( cost_by_message[message_id] = cost_by_message.get(message_id, 0.0) + (_record_cost(record) or 0.0) rework_total: Optional[float] = None for row in feedback_rows: - # Explicit thumbs only — implicit signals (spec §10) share the row - # family but answer a different question and must never be summed in. + # Explicit thumbs only below — implicit signals (spec §10) share the + # row family but answer a different question and are counted apart. if row.get("signal") not in (None, "explicit"): + if row.get("signal") == "implicit" and row.get("kind") in implicit_messages: + message_id = _as_int(row.get("messageId")) + if message_id is not None: + implicit_messages[row["kind"]].add(message_id) continue value = _as_int(row.get("value")) if value not in (1, -1): @@ -170,6 +176,11 @@ def _join_feedback( bucket.down += 1 profile.by_turn_class = buckets profile.rework_usd = round(rework_total, 6) if rework_total is not None else None + if any(implicit_messages.values()): + profile.implicit = ImplicitSignalCounts( + copied=len(implicit_messages["copy"]), + continued=len(implicit_messages["continue"]), + ) return profile diff --git a/backend/src/apis/app_api/messages/models.py b/backend/src/apis/app_api/messages/models.py index 7d70d919..fa93ef49 100644 --- a/backend/src/apis/app_api/messages/models.py +++ b/backend/src/apis/app_api/messages/models.py @@ -128,6 +128,24 @@ class Citation(BaseModel): FeedbackReason = Literal["wrong", "instructions", "length", "tool_failed", "outdated", "other"] +#: Implicit signals (response-feedback spec §10): denser than thumbs, no UI +#: cost, written to the same ``F#`` family under ``signal: "implicit"`` and +#: never summed with them. ``copy`` = the response was copied out; +#: ``continue`` = a truncated / interrupted response was resumed. Edit-and- +#: resend has no affordance in the SPA yet; abandonment is deferred (its +#: base rate is indistinguishable from a satisfied user going quiet). +IMPLICIT_SIGNAL_KINDS = ("copy", "continue") +ImplicitSignalKind = Literal["copy", "continue"] + + +class ImplicitSignalRequest(BaseModel): + """Body of ``POST /sessions/{id}/messages/{messageId}/signals``.""" + + model_config = ConfigDict(populate_by_name=True) + + kind: ImplicitSignalKind = Field(..., description="Which implicit signal fired (closed enum)") + + class MessageFeedback(BaseModel): """One user's thumb on one assistant message (``F#`` row, see ``apis.shared.sessions.metadata``). ``value`` is +1 (up) or -1 (down); diff --git a/backend/src/apis/app_api/sessions/routes.py b/backend/src/apis/app_api/sessions/routes.py index a2d0a57a..1552584e 100644 --- a/backend/src/apis/app_api/sessions/routes.py +++ b/backend/src/apis/app_api/sessions/routes.py @@ -21,11 +21,13 @@ MessagesListResponse, MessageFeedback, MessageFeedbackRequest, + ImplicitSignalRequest, ) from apis.shared.sessions.feedback import ( SessionNotOwned, delete_message_feedback, put_message_feedback, + record_implicit_signal, ) from apis.shared.sessions.messages import get_messages from apis.shared.sessions.metadata import ( @@ -746,6 +748,36 @@ async def delete_message_feedback_endpoint( return Response(status_code=204) +@router.post("/{session_id}/messages/{message_id}/signals", status_code=204) +async def record_implicit_signal_endpoint( + session_id: str, + message_id: int = Path(..., ge=0, description="0-based message index"), + body: ImplicitSignalRequest = ..., + current_user: User = Depends(get_current_user_from_session), +): + """Record an implicit signal (``copy`` / ``continue``) on an assistant + message — response-feedback spec §10. Fire-and-forget from the SPA: + always 204 once accepted, never a reason to show the user anything. + Content-free: the body is a closed enum.""" + _require_message_feedback() + try: + await record_implicit_signal( + session_id=session_id, + user_id=current_user.user_id, + message_id=message_id, + kind=body.kind, + ) + except SessionNotOwned: + raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") + except RuntimeError as e: + logger.warning("Message feedback unavailable: %s", scrub_log(str(e))) + raise HTTPException(status_code=503, detail="Message feedback is not available") + except Exception: + logger.error("Error recording implicit signal", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to record signal") + return Response(status_code=204) + + @router.post("/{session_id}/interrupt", status_code=204) async def signal_turn_interrupted_endpoint( session_id: str, diff --git a/backend/src/apis/shared/observability/content_policy.py b/backend/src/apis/shared/observability/content_policy.py index eb0ccde6..4c81d005 100644 --- a/backend/src/apis/shared/observability/content_policy.py +++ b/backend/src/apis/shared/observability/content_policy.py @@ -212,6 +212,8 @@ def is_content_bearing(path: str) -> bool: "value", "reason", "signal", + "kind", # implicit rows: copy / continue (closed enum) + "count", # implicit rows: how many times it fired "retryMessageId", # a message index, the retry-with-correction link "updatedAt", ) diff --git a/backend/src/apis/shared/sessions/feedback.py b/backend/src/apis/shared/sessions/feedback.py index 15ed464e..47629299 100644 --- a/backend/src/apis/shared/sessions/feedback.py +++ b/backend/src/apis/shared/sessions/feedback.py @@ -17,10 +17,17 @@ GSI_SK: F#{message_id} sessionId, messageId, userId, value, reason?, signal, retryMessageId?, updatedAt, ttl -``signal`` is ``"explicit"`` for a thumb. ``docs/specs/response-feedback.md`` -§10 adds implicit signals (copy, continue, edit-and-resend, abandonment) to -this same row family under ``signal: "implicit"``; every reader here filters -to explicit rows so that phase needs no backfill and the two are never summed. +``signal`` is ``"explicit"`` for a thumb. Implicit signals +(``docs/specs/response-feedback.md`` §10 — today ``copy`` and ``continue``) +live in the same family under their own key so they never collide with the +thumb:: + + SK: F#{session_id}#{message_id}#{kind} + GSI_SK: F#{message_id}#{kind} + sessionId, messageId, userId, signal="implicit", kind, count (ADD), updatedAt, ttl + +Every thumb reader filters to explicit rows, so the two are never summed +(§10's rule); the admin profile counts implicit rows per kind separately. ``retryMessageId`` is the index of the user message the SPA sent as a *retry with correction* after a down-thumb (response-feedback spec §7 "the @@ -53,7 +60,7 @@ from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional -from .models import FEEDBACK_REASONS, MessageFeedback +from .models import FEEDBACK_REASONS, IMPLICIT_SIGNAL_KINDS, MessageFeedback from .preview import is_preview_session logger = logging.getLogger(__name__) @@ -235,6 +242,40 @@ async def delete_message_feedback(session_id: str, user_id: str, message_id: int return True +async def record_implicit_signal(session_id: str, user_id: str, message_id: int, kind: str) -> None: + """Count one implicit signal on one message (``ADD count :one``). Same + ownership rule as a thumb; a kind outside :data:`IMPLICIT_SIGNAL_KINDS` + is refused so the family stays a closed vocabulary. No session rollup: + the rows are the read model and the profile counts them directly.""" + if kind not in IMPLICIT_SIGNAL_KINDS: + raise ValueError("implicit signal kind must be one of the fixed codes") + if is_preview_session(session_id): + return + table = _table() + await _owned_session_sk(session_id, user_id, table) + ttl = int((datetime.now(timezone.utc) + timedelta(days=FEEDBACK_TTL_DAYS)).timestamp()) + table.update_item( + Key={"PK": f"USER#{user_id}", "SK": f"{feedback_sk(session_id, message_id)}#{kind}"}, + UpdateExpression=( + "SET GSI_PK = :gpk, GSI_SK = :gsk, sessionId = :sid, messageId = :mid, userId = :uid, " + "#signal = :sig, kind = :kind, updatedAt = :now, #ttl = :ttl ADD #count :one" + ), + ExpressionAttributeNames={"#signal": "signal", "#ttl": "ttl", "#count": "count"}, + ExpressionAttributeValues={ + ":gpk": f"SESSION#{session_id}", + ":gsk": f"F#{message_id}#{kind}", + ":sid": session_id, + ":mid": int(message_id), + ":uid": user_id, + ":sig": "implicit", + ":kind": kind, + ":now": _now(), + ":ttl": ttl, + ":one": 1, + }, + ) + + def query_session_feedback(table, session_id: str, user_id: Optional[str] = None) -> Dict[str, MessageFeedback]: """All ``F#`` rows for a session via ``SessionLookupIndex``, keyed by message id (as ``str``, matching the metadata index). With ``user_id`` diff --git a/backend/src/apis/shared/sessions/models.py b/backend/src/apis/shared/sessions/models.py index 20e35922..cdf6a96d 100644 --- a/backend/src/apis/shared/sessions/models.py +++ b/backend/src/apis/shared/sessions/models.py @@ -653,6 +653,24 @@ class Citation(BaseModel): FeedbackReason = Literal["wrong", "instructions", "length", "tool_failed", "outdated", "other"] +#: Implicit signals (response-feedback spec §10): denser than thumbs, no UI +#: cost, written to the same ``F#`` family under ``signal: "implicit"`` and +#: never summed with them. ``copy`` = the response was copied out; +#: ``continue`` = a truncated / interrupted response was resumed. Edit-and- +#: resend has no affordance in the SPA yet; abandonment is deferred (its +#: base rate is indistinguishable from a satisfied user going quiet). +IMPLICIT_SIGNAL_KINDS = ("copy", "continue") +ImplicitSignalKind = Literal["copy", "continue"] + + +class ImplicitSignalRequest(BaseModel): + """Body of ``POST /sessions/{id}/messages/{messageId}/signals``.""" + + model_config = ConfigDict(populate_by_name=True) + + kind: ImplicitSignalKind = Field(..., description="Which implicit signal fired (closed enum)") + + class MessageFeedback(BaseModel): """One user's thumb on one assistant message (``F#`` row, see ``apis.shared.sessions.metadata``). ``value`` is +1 (up) or -1 (down); diff --git a/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py b/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py index 74cc883a..b33efaa1 100644 --- a/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py +++ b/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py @@ -108,7 +108,7 @@ def test_session_profile_returns_200(): "feedback": False, "documents": False, } - assert body["feedback"] == {"up": 0, "down": 0, "byTurnClass": None, "unjoined": 0, "retried": 0, "reworkUsd": None} + assert body["feedback"] == {"up": 0, "down": 0, "byTurnClass": None, "unjoined": 0, "retried": 0, "reworkUsd": None, "implicit": None} service.get_session_profile.assert_awaited_once_with("s1") diff --git a/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py b/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py index 5a5b75be..3a13216e 100644 --- a/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py +++ b/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py @@ -366,6 +366,23 @@ async def test_implicit_signal_rows_are_never_summed_into_the_thumb_counts(): assert (p.feedback.by_turn_class.full.up, p.feedback.by_turn_class.full.down) == (1, 1) +@pytest.mark.asyncio +async def test_implicit_signals_are_counted_as_messages_touched_per_kind(): + def implicit(message_id, kind, count): + return {"sessionId": "s1", "messageId": message_id, "signal": "implicit", "kind": kind, "count": count, "updatedAt": "t"} + + feedback = [implicit(0, "copy", 3), implicit(2, "copy", 1), implicit(2, "continue", 1), implicit(4, "weird", 1)] + p = await _service_with_feedback(_row(), [_call(0), _call(2)], feedback).get_session_profile("s1") + assert p.feedback.implicit is not None + assert (p.feedback.implicit.copied, p.feedback.implicit.continued) == (2, 1) + assert (p.feedback.up, p.feedback.down) == (0, 0) + assert p.data_coverage.feedback is True + assert p.model_dump(by_alias=True)["feedback"]["implicit"] == {"copied": 2, "continued": 1} + + p = await _service_with_feedback(_row(), [_call(0)], [_feedback(0, 1)]).get_session_profile("s1") + assert p.feedback.implicit is None + + @pytest.mark.asyncio async def test_feedback_reader_failure_never_breaks_the_profile(): service = _service(_row(), [_call(0)]) diff --git a/backend/tests/apis/app_api/test_message_feedback_routes.py b/backend/tests/apis/app_api/test_message_feedback_routes.py index 605af26c..3b6f6015 100644 --- a/backend/tests/apis/app_api/test_message_feedback_routes.py +++ b/backend/tests/apis/app_api/test_message_feedback_routes.py @@ -28,8 +28,9 @@ def _user() -> User: return User(user_id="user-1", email="u@example.com", name="U", roles=["default"], raw_token="tok") -def _client(monkeypatch, put=None, delete=None) -> TestClient: +def _client(monkeypatch, put=None, delete=None, signal=None) -> TestClient: monkeypatch.delenv("RESPONSE_FEEDBACK_ENABLED", raising=False) + monkeypatch.setattr(session_routes, "record_implicit_signal", signal or AsyncMock(return_value=None)) monkeypatch.setattr(session_routes, "put_message_feedback", put or AsyncMock( return_value=MessageFeedback(value=1, updated_at="2026-09-16T00:00:00Z") )) @@ -96,6 +97,17 @@ def test_kill_switch_hides_the_surface(monkeypatch): put.assert_not_awaited() +def test_implicit_signal_is_fire_and_forget_204(monkeypatch): + signal = AsyncMock(return_value=None) + client = _client(monkeypatch, signal=signal) + assert client.post("/sessions/s1/messages/3/signals", json={"kind": "copy"}).status_code == 204 + signal.assert_awaited_once_with(session_id="s1", user_id="user-1", message_id=3, kind="copy") + assert client.post("/sessions/s1/messages/3/signals", json={"kind": "abandon"}).status_code == 422 + assert client.post("/sessions/s1/messages/3/signals", json={"kind": "I copied it"}).status_code == 422 + monkeypatch.setenv("RESPONSE_FEEDBACK_ENABLED", "false") + assert client.post("/sessions/s1/messages/3/signals", json={"kind": "copy"}).status_code == 404 + + def test_missing_table_is_503_not_500(monkeypatch): client = _client(monkeypatch, put=AsyncMock(side_effect=RuntimeError("DYNAMODB_SESSIONS_METADATA_TABLE_NAME"))) assert client.put("/sessions/s1/messages/3/feedback", json={"value": 1}).status_code == 503 diff --git a/backend/tests/shared/test_message_feedback.py b/backend/tests/shared/test_message_feedback.py index aae508dd..809a03f7 100644 --- a/backend/tests/shared/test_message_feedback.py +++ b/backend/tests/shared/test_message_feedback.py @@ -196,6 +196,35 @@ async def test_implicit_signal_rows_share_the_family_but_never_read_as_thumbs(ta assert fb.is_explicit({"value": 1}) and not fb.is_explicit({"value": 1, "signal": "implicit"}) +@pytest.mark.asyncio +async def test_implicit_signals_count_under_their_own_key_beside_the_thumb(table): + """Spec §10: copy / continue rows share the F# family, keyed per kind so + they never collide with the thumb, ADD a count, and are invisible to + every thumb reader.""" + await fb.put_message_feedback(SESSION, OWNER, 3, -1, reason="wrong") + await fb.record_implicit_signal(SESSION, OWNER, 3, "copy") + await fb.record_implicit_signal(SESSION, OWNER, 3, "copy") + await fb.record_implicit_signal(SESSION, OWNER, 3, "continue") + + items = {i["SK"]: i for i in _feedback_items()} + assert set(items) == {f"F#{SESSION}#3", f"F#{SESSION}#3#copy", f"F#{SESSION}#3#continue"} + copy = items[f"F#{SESSION}#3#copy"] + assert copy["signal"] == "implicit" and copy["kind"] == "copy" and int(copy["count"]) == 2 + assert copy["GSI_SK"] == "F#3#copy" and int(copy["messageId"]) == 3 and "value" not in copy + assert int(items[f"F#{SESSION}#3"]["value"]) == -1, "the thumb is untouched" + + # Thumb readers see only the thumb; the session rollups did not move. + assert {k: v.value for k, v in fb.query_session_feedback(table, SESSION, OWNER).items()} == {"3": -1} + assert _session_row()["thumbsDown"] == 1 and _session_row()["thumbsUp"] == 0 + + with pytest.raises(ValueError): + await fb.record_implicit_signal(SESSION, OWNER, 3, "abandon") + with pytest.raises(fb.SessionNotOwned): + await fb.record_implicit_signal(SESSION, OTHER, 3, "copy") + await fb.record_implicit_signal("preview-x", OWNER, 0, "copy") + assert _feedback_items("preview-x") == [] + + @pytest.mark.asyncio async def test_messages_list_metadata_index_carries_feedback(table, monkeypatch): """The read path the SPA uses on reload: F# rows merge into the metadata diff --git a/docs/specs/response-feedback.md b/docs/specs/response-feedback.md index 8c1ab116..d029ef7f 100644 --- a/docs/specs/response-feedback.md +++ b/docs/specs/response-feedback.md @@ -3,7 +3,8 @@ **Status:** PARTIALLY BUILT — capture and the read model shipped in PR #1142 (2026-09-16, as document-context-offload PR-7) and the consequence (retry-with-correction) in the PR stacked on it; §11 PR-1 is therefore -complete. Implicit signals and eval sampling are not built. See §13. Written +complete, and §11 PR-2 (implicit signals) followed for copy and continue. +Eval sampling and the author surfaces are not built. See §13. Written 2026-09-04 from the "how would we benefit?" conversation. **Refs:** `docs/specs/agentcore-evaluations-spike-findings.md` (the eval harness this feeds), `docs/specs/mid-turn-steering.md` (the injection path @@ -368,7 +369,20 @@ points: so Phase 6 can find it once the consent decision is made. The profile reports `feedback.retried` and `reworkUsd` (§7 "rework cost": the thumbed call rows plus the retry turn's consecutive assistant rows). -- **Not built**: implicit signals (PR-2), eval sampling (PR-4), - author/marketplace surfaces (PR-5), the report-dialog escape hatch in the - reason row. +- **Implicit signals (§11 PR-2), third PR — copy and continue only.** + Same `F#` family under their own key, `F#{session}#{message}#{kind}`, so + they never collide with the thumb; `signal: "implicit"`, `kind`, and an + `ADD`ed `count`. `POST /sessions/{id}/messages/{message_id}/signals`, + fire-and-forget from the SPA (the Copy and Continue clicks in the actions + rail, once per message per kind per page load). The profile reports + `feedback.implicit` as *messages touched* per kind, on its own line — the + §10 rule that explicit and implicit are never summed is enforced in every + reader. Two of §10's four signals are deliberately not here: + **edit-and-resend** has no affordance in the SPA to hook, and + **abandonment** is deferred — a session that goes quiet after a good + answer is indistinguishable from one that goes quiet after a bad one, so + it needs its own design (or the §10 "dissatisfaction in the next message" + offline classifier) before it is worth a row. +- **Not built**: eval sampling (PR-4), author/marketplace surfaces (PR-5), + the report-dialog escape hatch in the reason row, abandonment. diff --git a/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts b/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts index f58fc184..21d67211 100644 --- a/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts +++ b/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts @@ -442,6 +442,8 @@ export interface FeedbackProfile { /** Down-thumbs followed by a retry-with-correction, and what the rework cost. */ retried?: number; reworkUsd?: number | null; + /** Implicit signals as messages touched per kind; null when none. Never summed with thumbs. */ + implicit?: { copied: number; continued: number } | null; } /** The content-free diagnostic profile of one conversation. */ diff --git a/frontend/ai.client/src/app/admin/costs/pages/session-cost-anatomy.page.ts b/frontend/ai.client/src/app/admin/costs/pages/session-cost-anatomy.page.ts index f12d35a0..05b646eb 100644 --- a/frontend/ai.client/src/app/admin/costs/pages/session-cost-anatomy.page.ts +++ b/frontend/ai.client/src/app/admin/costs/pages/session-cost-anatomy.page.ts @@ -38,6 +38,7 @@ import { buildDiagnosticJson, downRate, feedbackByTurnClassLine, + feedbackImplicitLine, feedbackRetryLine, formatBytes, formatEvidenceValue, @@ -257,6 +258,11 @@ import {

{{ retries }}

} + @if (feedbackImplicitLine(); as implicit) { + +

{{ implicit }}

+ } } @else {

not tracked

@@ -843,6 +849,10 @@ export class SessionCostAnatomyPage { return feedback ? downRate(feedback) : null; }); + readonly feedbackImplicitLine = computed(() => + this.profileResource.hasValue() ? feedbackImplicitLine(this.profileResource.value().feedback) : null, + ); + readonly feedbackRetryLine = computed(() => this.profileResource.hasValue() ? feedbackRetryLine(this.profileResource.value().feedback) : null, ); diff --git a/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.spec.ts b/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.spec.ts index ec9ad88a..03e2c95c 100644 --- a/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.spec.ts +++ b/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.spec.ts @@ -4,6 +4,7 @@ import { cleanCeiling, downRate, feedbackByTurnClassLine, + feedbackImplicitLine, feedbackRetryLine, formatBytes, formatEvidenceValue, @@ -50,6 +51,13 @@ describe('session-profile.util', () => { expect(feedbackRetryLine({ up: 0, down: 2, retried: 2, reworkUsd: 0.351 })).toBe('2 retried · $0.35 rework'); }); + it('feedbackImplicitLine names implicit signals apart from the thumbs', () => { + expect(feedbackImplicitLine({ up: 1, down: 0 })).toBeNull(); + expect(feedbackImplicitLine({ up: 1, down: 0, implicit: null })).toBeNull(); + expect(feedbackImplicitLine({ up: 1, down: 0, implicit: { copied: 0, continued: 0 } })).toBeNull(); + expect(feedbackImplicitLine({ up: 0, down: 0, implicit: { copied: 3, continued: 1 } })).toBe('3 copied · 1 continued'); + }); + it('feedbackByTurnClassLine is null when the turn class is not tracked', () => { expect(feedbackByTurnClassLine({ up: 1, down: 1, byTurnClass: null })).toBeNull(); expect(feedbackByTurnClassLine({ up: 1, down: 1 })).toBeNull(); diff --git a/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.ts b/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.ts index eb7a63ff..86fbb1c5 100644 --- a/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.ts +++ b/frontend/ai.client/src/app/admin/costs/pages/session-profile.util.ts @@ -241,6 +241,16 @@ export function feedbackRetryLine(feedback: FeedbackProfile | null | undefined): return parts.join(' · '); } +/** `3 copied · 1 continued` — implicit signals, kept off the thumbs line. */ +export function feedbackImplicitLine(feedback: FeedbackProfile | null | undefined): string | null { + const implicit = feedback?.implicit; + if (!implicit) return null; + const parts: string[] = []; + if (implicit.copied > 0) parts.push(`${implicit.copied} copied`); + if (implicit.continued > 0) parts.push(`${implicit.continued} continued`); + return parts.length > 0 ? parts.join(' · ') : null; +} + export function feedbackByTurnClassLine(feedback: FeedbackProfile | null | undefined): string | null { const by = feedback?.byTurnClass; if (!by) return null; diff --git a/frontend/ai.client/src/app/session/components/message-list/components/message-actions.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/message-actions.component.spec.ts index 7c5bc927..a8d4b48a 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/message-actions.component.spec.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/message-actions.component.spec.ts @@ -134,6 +134,10 @@ class FakeFeedbackService { requestRetry(message: Message): void { this.retried.push(message.id); } + signals: Array<{ id: string; kind: string }> = []; + recordSignal(message: Message, kind: string): void { + this.signals.push({ id: message.id, kind }); + } feedbackFor(): MessageFeedback | null { return this.current; } @@ -235,6 +239,16 @@ describe('MessageActionsComponent — thumbs feedback', () => { expect(feedback.set).toEqual([]); }); + it('Continue records the implicit signal on the run\'s last message and still emits', () => { + fixture.componentRef.setInput('canContinue', true); + fixture.detectChanges(); + let emitted = 0; + fixture.componentInstance.continueRequested.subscribe(() => emitted++); + (fixture.nativeElement.querySelector('button[aria-label="Continue the truncated response"]') as HTMLButtonElement).click(); + expect(emitted).toBe(1); + expect(feedback.signals).toEqual([{ id: 'msg-sess-1-3', kind: 'continue' }]); + }); + it('reason codes stay hidden on a thumbs up', () => { feedback.current = { value: 1, updatedAt: 't' }; fixture.detectChanges(); diff --git a/frontend/ai.client/src/app/session/components/message-list/components/message-actions.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/message-actions.component.ts index a329651e..ca5f853d 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/message-actions.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/message-actions.component.ts @@ -121,7 +121,7 @@ import { TooltipDirective } from '../../../../components/tooltip'; appTooltip="Resume response" appTooltipPosition="top" aria-label="Continue the truncated response" - (click)="continueRequested.emit()" + (click)="onContinue()" >