Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/apis/app_api/admin/costs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,12 @@ class FeedbackProfile(BaseModel):
down: int = 0
by_turn_class: Optional[FeedbackByTurnClass] = Field(None, alias="byTurnClass")
unjoined: int = 0
# Down-thumbs the user followed with a retry-with-correction, and what
# that rework cost: the thumbed call(s) plus the retry turn's calls
# (response-feedback spec §7 "rework cost"). ``None`` when no retry has
# a cost row to price.
retried: int = 0
rework_usd: Optional[float] = Field(None, alias="reworkUsd")


class DataCoverage(BaseModel):
Expand Down
37 changes: 37 additions & 0 deletions backend/src/apis/app_api/admin/costs/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ 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()
# Every cost row per assistant message index, for pricing rework.
cost_by_message: Dict[int, float] = {}
for record in records:
message_id = _as_int(record.get("messageId"))
if message_id is not None:
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.
Expand All @@ -139,6 +146,12 @@ def _join_feedback(
else:
profile.down += 1
message_id = _as_int(row.get("messageId"))
retry_id = _as_int(row.get("retryMessageId"))
if value == -1 and retry_id is not None:
profile.retried += 1
rework = _rework_cost(cost_by_message, message_id, retry_id)
if rework is not None:
rework_total = (rework_total or 0.0) + rework
record = by_message.get(message_id) if message_id is not None else None
if record is None:
profile.unjoined += 1
Expand All @@ -156,9 +169,33 @@ def _join_feedback(
else:
bucket.down += 1
profile.by_turn_class = buckets
profile.rework_usd = round(rework_total, 6) if rework_total is not None else None
return profile


def _rework_cost(
cost_by_message: Dict[int, float],
thumbed_message_id: Optional[int],
retry_message_id: int,
) -> Optional[float]:
"""Dollars spent on a down-thumbed answer plus its retry: the thumbed
message's call rows, plus the retry turn's assistant rows. The retry is
a *user* message (no cost row); its turn's assistant messages are the
consecutive indexes after it — a gap means the next user message. ``None``
when neither side has a cost row to price."""
found = False
total = 0.0
if thumbed_message_id is not None and thumbed_message_id in cost_by_message:
total += cost_by_message[thumbed_message_id]
found = True
index = retry_message_id + 1
while index in cost_by_message:
total += cost_by_message[index]
found = True
index += 1
return total if found else None


def _context_tokens(record: Dict[str, Any]) -> int:
"""True context occupancy of one call: uncached input + cached prefix +
newly cached tokens (Bedrock reports the three disjointly)."""
Expand Down
8 changes: 8 additions & 0 deletions backend/src/apis/app_api/messages/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ class MessageFeedback(BaseModel):

value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
retry_message_id: Optional[int] = Field(
None, alias="retryMessageId", ge=0,
description="Index of the user message sent as a retry-with-correction after this thumb (content-free link)",
)
updated_at: str = Field(..., alias="updatedAt", description="ISO timestamp of the latest thumb")


Expand All @@ -147,6 +151,10 @@ class MessageFeedbackRequest(BaseModel):

value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
retry_message_id: Optional[int] = Field(
None, alias="retryMessageId", ge=0,
description="Set when the user sent a retry-with-correction: that user message's index",
)


class MessageMetadata(BaseModel):
Expand Down
5 changes: 4 additions & 1 deletion backend/src/apis/app_api/sessions/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,9 @@ async def put_message_feedback_endpoint(

``message_id`` is the message's 0-based index in the conversation — the
trailing number of the SPA's ``msg-{sessionId}-{index}`` id, and the
``messageId`` the message's cost row carries.
``messageId`` the message's cost row carries. ``retryMessageId`` in the
body links the user message sent as a retry-with-correction; it is kept
across later thumbs on the same message.
"""
_require_message_feedback()
try:
Expand All @@ -704,6 +706,7 @@ async def put_message_feedback_endpoint(
message_id=message_id,
value=body.value,
reason=body.reason,
retry_message_id=body.retry_message_id,
)
except SessionNotOwned:
raise HTTPException(status_code=404, detail=f"Session not found: {session_id}")
Expand Down
1 change: 1 addition & 0 deletions backend/src/apis/shared/observability/content_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ def is_content_bearing(path: str) -> bool:
"value",
"reason",
"signal",
"retryMessageId", # a message index, the retry-with-correction link
"updatedAt",
)

Expand Down
64 changes: 52 additions & 12 deletions backend/src/apis/shared/sessions/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,19 @@
SK: F#{session_id}#{message_id}
GSI_PK: SESSION#{session_id} (SessionLookupIndex)
GSI_SK: F#{message_id}
sessionId, messageId, userId, value, reason?, signal, updatedAt, ttl
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.

``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
retry loop", §11 PR-1's consequence). It is a link, never the correction's
text: the profile counts retries and prices the rework from the cost rows
the link points at. A later re-thumb without the field keeps the link.

``messageId`` is the same 0-based index the ``C#`` cost row carries for the
assistant message, so an admin read joins feedback to the call's turn class
(``hasDocuments`` / ``documentDigests`` / ``documentReads``, #1137) on
Expand Down Expand Up @@ -82,9 +88,11 @@ def _keys(user_id: str, session_id: str, message_id: int) -> Dict[str, str]:


def _to_model(item: Dict[str, Any]) -> MessageFeedback:
retry = item.get("retryMessageId")
return MessageFeedback(
value=int(item.get("value", 0)),
reason=item.get("reason") if item.get("reason") in FEEDBACK_REASONS else None,
retry_message_id=int(retry) if retry is not None else None,
updated_at=str(item.get("updatedAt", "")),
)

Expand Down Expand Up @@ -124,9 +132,16 @@ async def put_message_feedback(
message_id: int,
value: int,
reason: Optional[str] = None,
retry_message_id: Optional[int] = None,
) -> MessageFeedback:
"""Write (or replace) this user's thumb on one message.

An upsert (``update_item``): ``value`` / ``reason`` / ``updatedAt`` are
replaced on every call, ``retryMessageId`` is set when given and kept
otherwise, so a user who thumbs again after retrying does not lose the
link. ``ReturnValues=ALL_OLD`` tells us what a replace replaced, so the
session rollups move by the delta rather than double-counting.

Raises :class:`SessionNotOwned` when the session is not this user's, and
``ValueError`` on a value outside ``{1, -1}`` or a reason outside
:data:`FEEDBACK_REASONS` — the route's request model already rejects
Expand All @@ -136,35 +151,60 @@ async def put_message_feedback(
raise ValueError("feedback value must be 1 or -1")
if reason is not None and reason not in FEEDBACK_REASONS:
raise ValueError("feedback reason must be one of the fixed codes")
if retry_message_id is not None and (not isinstance(retry_message_id, int) or retry_message_id < 0):
raise ValueError("retryMessageId must be a non-negative message index")
if is_preview_session(session_id):
# Preview sessions persist nothing; echo the thumb so the UI is consistent.
return MessageFeedback(value=value, reason=reason, updated_at=_now())
return MessageFeedback(value=value, reason=reason, retry_message_id=retry_message_id, updated_at=_now())

table = _table()
session_sk = await _owned_session_sk(session_id, user_id, table)

now = _now()
ttl = int((datetime.now(timezone.utc) + timedelta(days=FEEDBACK_TTL_DAYS)).timestamp())
item: Dict[str, Any] = {
**_keys(user_id, session_id, message_id),
sets = {
"GSI_PK": f"SESSION#{session_id}",
"GSI_SK": f"F#{message_id}",
"sessionId": session_id,
"messageId": int(message_id),
"userId": user_id,
"value": int(value),
"signal": "explicit",
"#value": int(value),
"#signal": "explicit",
"updatedAt": now,
"ttl": ttl,
"#ttl": ttl,
}
if reason:
item["reason"] = reason

# ReturnValues=ALL_OLD tells us what a replace is replacing, so the
# session rollups move by the delta rather than double-counting.
response = table.put_item(Item=item, ReturnValues="ALL_OLD")
sets["reason"] = reason
if retry_message_id is not None:
sets["retryMessageId"] = int(retry_message_id)
names = {"#value": "value", "#signal": "signal", "#ttl": "ttl"}
values: Dict[str, Any] = {}
set_parts = []
for i, (attr, val) in enumerate(sets.items()):
placeholder = f":v{i}"
values[placeholder] = val
set_parts.append(f"{attr} = {placeholder}")
expression = "SET " + ", ".join(set_parts)
if not reason:
expression += " REMOVE reason"

response = table.update_item(
Key=_keys(user_id, session_id, message_id),
UpdateExpression=expression,
ExpressionAttributeNames=names,
ExpressionAttributeValues=values,
ReturnValues="ALL_OLD",
)
previous = response.get("Attributes") or {}
previous_value = int(previous.get("value", 0)) if previous else 0
item: Dict[str, Any] = {
"value": int(value),
"reason": reason,
"retryMessageId": (
int(retry_message_id) if retry_message_id is not None else previous.get("retryMessageId")
),
"updatedAt": now,
}

up = (1 if value == 1 else 0) - (1 if previous_value == 1 else 0)
down = (1 if value == -1 else 0) - (1 if previous_value == -1 else 0)
Expand Down
8 changes: 8 additions & 0 deletions backend/src/apis/shared/sessions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,10 @@ class MessageFeedback(BaseModel):

value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
retry_message_id: Optional[int] = Field(
None, alias="retryMessageId", ge=0,
description="Index of the user message sent as a retry-with-correction after this thumb (content-free link)",
)
updated_at: str = Field(..., alias="updatedAt", description="ISO timestamp of the latest thumb")


Expand All @@ -672,6 +676,10 @@ class MessageFeedbackRequest(BaseModel):

value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
retry_message_id: Optional[int] = Field(
None, alias="retryMessageId", ge=0,
description="Set when the user sent a retry-with-correction: that user message's index",
)


class MessageMetadata(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
assert body["feedback"] == {"up": 0, "down": 0, "byTurnClass": None, "unjoined": 0, "retried": 0, "reworkUsd": None}
service.get_session_profile.assert_awaited_once_with("s1")


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,29 @@ async def test_no_feedback_rows_falls_back_to_rollups_and_coverage_is_honest():
assert p.feedback.by_turn_class is None


@pytest.mark.asyncio
async def test_retries_are_counted_and_rework_is_priced_from_the_cost_rows():
# Turn A: assistant 1 (thumbed down, $0.10). Retry sent as user message 2;
# its turn is assistant 3 + 4 ($0.20 + $0.05, a tool round trip). User 5,
# assistant 6 ($0.99) is the NEXT turn and must not be counted.
records = [_call(1, cost=0.10), _call(3, cost=0.20), _call(4, cost=0.05), _call(6, cost=0.99)]
feedback = [{**_feedback(1, -1, "wrong"), "retryMessageId": 2}, _feedback(6, 1)]
p = await _service_with_feedback(_row(), records, feedback).get_session_profile("s1")
assert p.feedback.retried == 1
assert p.feedback.rework_usd == 0.35
assert (p.feedback.up, p.feedback.down) == (1, 1)

# A retry whose turn has no cost rows yet (still streaming) counts, but prices only the thumbed side.
feedback = [{**_feedback(1, -1, "wrong"), "retryMessageId": 7}]
p = await _service_with_feedback(_row(), records, feedback).get_session_profile("s1")
assert p.feedback.retried == 1 and p.feedback.rework_usd == 0.10

# Nothing priced at all → None, not 0.
feedback = [{**_feedback(9, -1), "retryMessageId": 10}]
p = await _service_with_feedback(_row(), records, feedback).get_session_profile("s1")
assert p.feedback.retried == 1 and p.feedback.rework_usd is None


@pytest.mark.asyncio
async def test_implicit_signal_rows_are_never_summed_into_the_thumb_counts():
records = [_call(0)]
Expand Down
11 changes: 10 additions & 1 deletion backend/tests/apis/app_api/test_message_feedback_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,16 @@ def test_put_stores_and_echoes_the_thumb(monkeypatch):

assert resp.status_code == 200
assert resp.json() == {"value": -1, "reason": "instructions", "updatedAt": "2026-09-16T00:00:00Z"}
put.assert_awaited_once_with(session_id="s1", user_id="user-1", message_id=3, value=-1, reason="instructions")
put.assert_awaited_once_with(session_id="s1", user_id="user-1", message_id=3, value=-1, reason="instructions", retry_message_id=None)


def test_put_forwards_the_retry_link(monkeypatch):
put = AsyncMock(return_value=MessageFeedback(value=-1, retry_message_id=4, updated_at="t"))
client = _client(monkeypatch, put=put)
resp = client.put("/sessions/s1/messages/3/feedback", json={"value": -1, "retryMessageId": 4})
assert resp.status_code == 200 and resp.json()["retryMessageId"] == 4
assert put.await_args.kwargs["retry_message_id"] == 4
assert client.put("/sessions/s1/messages/3/feedback", json={"value": -1, "retryMessageId": -1}).status_code == 422


def test_put_rejects_free_text_and_out_of_range_values(monkeypatch):
Expand Down
24 changes: 23 additions & 1 deletion backend/tests/shared/test_message_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ async def test_put_writes_one_row_keyed_beside_the_cost_row(table):
assert "ttl" in row
# Content-free: nothing on the row but ids, a number, a timestamp and keys.
assert row["signal"] == "explicit"
assert set(row) <= {"PK", "SK", "GSI_PK", "GSI_SK", "sessionId", "messageId", "userId", "value", "signal", "updatedAt", "ttl"}
assert set(row) <= {"PK", "SK", "GSI_PK", "GSI_SK", "sessionId", "messageId", "userId", "value", "signal", "retryMessageId", "updatedAt", "ttl"}


@pytest.mark.asyncio
Expand All @@ -97,6 +97,28 @@ async def test_second_thumb_replaces_the_first_and_rollups_follow(table):
assert _feedback_items()[0]["reason"] == "wrong"


@pytest.mark.asyncio
async def test_retry_link_is_set_once_and_kept_across_later_thumbs(table):
"""Retry-with-correction (response-feedback §7): the row links the user
message the correction was sent as, and a later re-thumb without the
field must not drop it."""
first = await fb.put_message_feedback(SESSION, OWNER, 3, -1, reason="wrong")
assert first.retry_message_id is None
linked = await fb.put_message_feedback(SESSION, OWNER, 3, -1, reason="wrong", retry_message_id=4)
assert linked.retry_message_id == 4
assert int(_feedback_items()[0]["retryMessageId"]) == 4

# Re-thumb (say, the retry was better and they flip to up): link stays.
again = await fb.put_message_feedback(SESSION, OWNER, 3, 1)
assert again.value == 1 and again.retry_message_id == 4
row = _feedback_items()[0]
assert int(row["retryMessageId"]) == 4 and "reason" not in row
assert _session_row()["thumbsUp"] == 1 and _session_row()["thumbsDown"] == 0

with pytest.raises(ValueError):
await fb.put_message_feedback(SESSION, OWNER, 3, -1, retry_message_id=-1)


@pytest.mark.asyncio
async def test_delete_removes_the_row_and_decrements(table):
await fb.put_message_feedback(SESSION, OWNER, 1, -1, reason="tool_failed")
Expand Down
24 changes: 19 additions & 5 deletions docs/specs/response-feedback.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Response feedback

**Status:** PARTIALLY BUILT — capture and the read model shipped in PR #1142
(2026-09-16, as document-context-offload PR-7); the consequence (§11 PR-1's
retry-with-correction), implicit signals and eval sampling are not built.
See §13. Written 2026-09-04 from the "how would we benefit?" conversation.
(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
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
Phase 1 reuses), `docs/specs/agent-marketplace.md` D15 (the *other* feedback
Expand Down Expand Up @@ -353,8 +354,21 @@ points:
`agentSwitched`, skills) are further buckets in the same join loop.
- **Open question 4 settled**: preview sessions echo the thumb and persist
nothing, matching the `D#` write. Preview data never reaches aggregates.
- **Not built**: retry-with-correction (§11 PR-1's consequence — open
question 1 still stands), implicit signals (PR-2), eval sampling (PR-4),
- **Retry-with-correction (the consequence), second PR.** Open question 1
settled: **a new turn, not `/steer`** — the steer path targets a *running*
turn through the lease row, and a finished turn is corrected by an
ordinary next message. The down-thumb reason row offers *Retry with that
in mind*, which prefills the composer (`ComposerDraftService`) with a
correction template for the reason code; the user edits and sends it as a
normal message, so it goes where messages go (AgentCore Memory) and never
touches the metadata table. When that message is added, the send path
links it to the thumb as `retryMessageId` on the `F#` row — an index,
never the text — and a later re-thumb keeps the link. This is open
question 2's answer for now: the pair is *recorded* without its content,
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.

Loading