Skip to content

Commit 05fe010

Browse files
committed
fix: address review findings (iteration 1) - F003, F007, F008, F009, F016
- F009: fix I001 import order in playbook_extractor - F003: drop always-None session_id kwarg from learning billing events (no session_id source on generation path) - F016: rename _last_resumable_trace -> _last_resumable_token_totals - F007: add unit tests for token_accounting + count_input_tokens - F008: add per-endpoint applied-learnings metering tests (4 endpoints x 3 scenarios)
1 parent 6352693 commit 05fe010

6 files changed

Lines changed: 309 additions & 18 deletions

File tree

reflexio/server/services/base_generation_service.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,13 @@ def _record_billing_learning_events(
344344
platform_llm = platform_llm_from_config(config)
345345
ctx = self._usage_context()
346346

347+
# session_id is intentionally not passed: the generation path has no
348+
# session_id source. _usage_context() never includes it, and neither
349+
# the Profile/Playbook service configs nor their requests carry a
350+
# session_id (unlike the Application-line path in server/api.py, which
351+
# reads it from the publish payload). Learning events therefore meter
352+
# without session attribution by design.
353+
347354
# ② Learning — value: learnings generated (helper no-ops on count <= 0).
348355
record_learnings_generated(
349356
org_id=ctx["org_id"],
@@ -352,12 +359,13 @@ def _record_billing_learning_events(
352359
platform_storage=None,
353360
pipeline=ctx.get("pipeline"),
354361
request_id=ctx.get("request_id"),
355-
session_id=ctx.get("session_id"),
356362
)
357363

358364
# ② Learning — cost: input-anchored extraction tokens + real provider tokens.
359365
totals = self._last_token_totals or RunTokenTotals()
360-
billing_input_tokens = count_input_tokens(self._extraction_input_text(prepared))
366+
billing_input_tokens = count_input_tokens(
367+
self._extraction_input_text(prepared)
368+
)
361369
record_extraction_tokens(
362370
org_id=ctx["org_id"],
363371
billing_input_tokens=billing_input_tokens,
@@ -367,7 +375,6 @@ def _record_billing_learning_events(
367375
platform_storage=None,
368376
pipeline=ctx.get("pipeline"),
369377
request_id=ctx.get("request_id"),
370-
session_id=ctx.get("session_id"),
371378
)
372379
except Exception:
373380
logger.warning(

reflexio/server/services/playbook/playbook_extractor.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
from reflexio.server.api_endpoints.request_context import RequestContext
1111
from reflexio.server.llm.litellm_client import LiteLLMClient
1212
from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
13-
from reflexio.server.services.extraction.outcome import ExtractionOutcome
1413
from reflexio.server.llm.token_accounting import RunTokenTotals, sum_trace_tokens
14+
from reflexio.server.services.extraction.outcome import ExtractionOutcome
1515
from reflexio.server.services.extraction.resumable_agent import (
1616
run_resumable_extraction_agent,
1717
)
@@ -80,7 +80,7 @@ def __init__(
8080
self.service_config: PlaybookGenerationServiceConfig = service_config
8181
self.agent_context: str = agent_context
8282
self._last_resumable_run_id: str | None = None
83-
self._last_resumable_trace: RunTokenTotals | None = None
83+
self._last_resumable_token_totals: RunTokenTotals | None = None
8484

8585
# Get LLM config overrides from configuration
8686
config = self.request_context.configurator.get_config()
@@ -225,7 +225,7 @@ def run(self) -> list[UserPlaybook] | ExtractionOutcome[UserPlaybook]:
225225
return ExtractionOutcome.completed(
226226
user_playbooks,
227227
run_id=self._last_resumable_run_id,
228-
token_totals=self._last_resumable_trace,
228+
token_totals=self._last_resumable_token_totals,
229229
)
230230
return user_playbooks
231231

@@ -321,7 +321,7 @@ def extract_playbook_entries(
321321
log_label="Playbook extraction",
322322
)
323323
self._last_resumable_run_id = result.run_id
324-
self._last_resumable_trace = sum_trace_tokens(result.trace)
324+
self._last_resumable_token_totals = sum_trace_tokens(result.trace)
325325
if not isinstance(result.output, StructuredPlaybookList):
326326
logger.warning(
327327
"Playbook extraction did not finish: %s",

reflexio/server/services/profile/profile_extractor.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def __init__(
8484
self.service_config: ProfileGenerationServiceConfig = service_config
8585
self.agent_context = agent_context
8686
self._last_resumable_run_id: str | None = None
87-
self._last_resumable_trace: RunTokenTotals | None = None
87+
self._last_resumable_token_totals: RunTokenTotals | None = None
8888

8989
# Get LLM config overrides from configuration
9090
config = self.request_context.configurator.get_config()
@@ -245,7 +245,8 @@ def run(self) -> list[UserProfile] | ExtractionOutcome[UserProfile] | None:
245245
# `or` is safe: a dataclass instance (even RunTokenTotals(0,0)) is
246246
# always truthy, so a real-but-zero total is never overwritten by
247247
# the fallback trace.
248-
token_totals=raw_profiles.token_totals or self._last_resumable_trace,
248+
token_totals=raw_profiles.token_totals
249+
or self._last_resumable_token_totals,
249250
)
250251
user_profiles = self._convert_raw_to_user_profiles(
251252
raw_profiles=raw_profiles or [],
@@ -265,7 +266,7 @@ def run(self) -> list[UserProfile] | ExtractionOutcome[UserProfile] | None:
265266
return ExtractionOutcome.completed(
266267
user_profiles,
267268
run_id=self._last_resumable_run_id,
268-
token_totals=self._last_resumable_trace,
269+
token_totals=self._last_resumable_token_totals,
269270
)
270271
return user_profiles or None
271272

@@ -400,7 +401,7 @@ def _generate_raw_updates_from_sessions(
400401
log_label="Profile extraction",
401402
)
402403
self._last_resumable_run_id = result.run_id
403-
self._last_resumable_trace = sum_trace_tokens(result.trace)
404+
self._last_resumable_token_totals = sum_trace_tokens(result.trace)
404405
if not isinstance(result.output, StructuredProfilesOutput):
405406
logger.warning(
406407
"Profile extraction did not finish: %s", result.finished_reason

tests/server/api_endpoints/test_applied_learnings_metering.py

Lines changed: 160 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88
from contextlib import contextmanager
99
from unittest.mock import MagicMock, patch
1010

11+
import pytest
1112
from fastapi.testclient import TestClient
1213

13-
from reflexio.models.api_schema.ui.entities import AgentPlaybookView, ProfileView
14+
from reflexio.models.api_schema.ui.entities import (
15+
AgentPlaybookView,
16+
ProfileView,
17+
UserPlaybookView,
18+
)
1419
from reflexio.server.api import create_app
1520
from reflexio.server.usage_metrics import UsageEvent, configure_usage_event_recorder
1621

@@ -29,6 +34,10 @@ def _make_agent_playbook_view() -> AgentPlaybookView:
2934
return AgentPlaybookView(agent_version="v1", content="content")
3035

3136

37+
def _make_user_playbook_view() -> UserPlaybookView:
38+
return UserPlaybookView(agent_version="v1", request_id="r1", content="content")
39+
40+
3241
def _client(caller_type: str) -> TestClient:
3342
app = create_app(get_org_id=lambda: "test-org", get_caller_type=lambda: caller_type)
3443
return TestClient(app, raise_server_exceptions=False)
@@ -90,7 +99,9 @@ def test_production_agent_search_meters_surfaced_count() -> None:
9099

91100
applied = [e for e in events if e.event_name == "learning_applied"]
92101
assert len(applied) == 1
93-
assert applied[0].count_value == 3 # 2 profiles + 1 agent_playbook + 0 user_playbooks
102+
assert (
103+
applied[0].count_value == 3
104+
) # 2 profiles + 1 agent_playbook + 0 user_playbooks
94105
assert applied[0].caller_type == "production_agent"
95106

96107

@@ -99,7 +110,9 @@ def test_dashboard_search_meters_nothing() -> None:
99110
events = _capture()
100111
try:
101112
with _patch_unified_search([_make_profile_view()], [], []):
102-
_client("dashboard").post("/api/search", json={"query": "x", "user_id": "u1"})
113+
_client("dashboard").post(
114+
"/api/search", json={"query": "x", "user_id": "u1"}
115+
)
103116
finally:
104117
configure_usage_event_recorder(None)
105118

@@ -140,11 +153,13 @@ def test_metering_failure_does_not_break_search_response() -> None:
140153
mock_response.user_playbooks = []
141154
mock_reflexio_search.unified_search.return_value = mock_response
142155
# Make get_config raise so metering blows up after the search completes.
143-
mock_reflexio_search.request_context.configurator.get_config.side_effect = RuntimeError(
144-
"boom"
156+
mock_reflexio_search.request_context.configurator.get_config.side_effect = (
157+
RuntimeError("boom")
145158
)
146159

147-
with patch("reflexio.server.api.get_reflexio", return_value=mock_reflexio_search):
160+
with patch(
161+
"reflexio.server.api.get_reflexio", return_value=mock_reflexio_search
162+
):
148163
resp = _client("production_agent").post(
149164
"/api/search", json={"query": "x", "user_id": "u1"}
150165
)
@@ -154,3 +169,142 @@ def test_metering_failure_does_not_break_search_response() -> None:
154169

155170
# Metering failed silently — no learning_applied event should have been emitted.
156171
assert [e for e in events if e.event_name == "learning_applied"] == []
172+
173+
174+
# --- Per-endpoint metering (the four non-unified routes) -----------------------
175+
#
176+
# Each of these endpoints calls a distinct service method on get_reflexio and
177+
# derives surfaced_count from a distinct response list attribute. The cases below
178+
# exercise the real endpoint handler + view conversion + _meter_applied_learnings
179+
# wiring for each, asserting the emitted surfaced_count matches that route's shape.
180+
181+
182+
@contextmanager
183+
def _patch_service_method(method_name: str, response_attr: str, items: list):
184+
"""Patch get_reflexio so ``method_name`` returns a canned service response.
185+
186+
The response carries ``items`` on ``response_attr`` (e.g. ``user_profiles``)
187+
so the endpoint's view conversion and surfaced_count computation run for real.
188+
get_config() returns None so platform_llm_from_config(None) is True without
189+
iterating a MagicMock.
190+
"""
191+
mock_reflexio = MagicMock()
192+
mock_response = MagicMock()
193+
mock_response.success = True
194+
mock_response.msg = "OK"
195+
setattr(mock_response, response_attr, items)
196+
getattr(mock_reflexio, method_name).return_value = mock_response
197+
mock_reflexio.request_context.configurator.get_config.return_value = None
198+
199+
with patch("reflexio.server.api.get_reflexio", return_value=mock_reflexio):
200+
yield
201+
202+
203+
# (path, payload, service method, response attribute, surfaced item factory)
204+
_ENDPOINT_CASES = [
205+
pytest.param(
206+
"/api/search_profiles",
207+
{"user_id": "u1", "query": "x"},
208+
"search_user_profiles",
209+
"user_profiles",
210+
_make_profile_view,
211+
id="search_profiles",
212+
),
213+
pytest.param(
214+
"/api/search_user_playbooks",
215+
{"query": "x"},
216+
"search_user_playbooks",
217+
"user_playbooks",
218+
_make_user_playbook_view,
219+
id="search_user_playbooks",
220+
),
221+
pytest.param(
222+
"/api/search_agent_playbooks",
223+
{"query": "x"},
224+
"search_agent_playbooks",
225+
"agent_playbooks",
226+
_make_agent_playbook_view,
227+
id="search_agent_playbooks",
228+
),
229+
pytest.param(
230+
"/api/get_agent_playbooks",
231+
{},
232+
"get_agent_playbooks",
233+
"agent_playbooks",
234+
_make_agent_playbook_view,
235+
id="get_agent_playbooks",
236+
),
237+
]
238+
239+
240+
@pytest.mark.parametrize(
241+
("path", "payload", "method_name", "response_attr", "make_item"),
242+
_ENDPOINT_CASES,
243+
)
244+
def test_production_agent_per_endpoint_meters_surfaced_count(
245+
path: str,
246+
payload: dict,
247+
method_name: str,
248+
response_attr: str,
249+
make_item,
250+
) -> None:
251+
"""Each non-unified route emits one learning_applied event with its own count."""
252+
events = _capture()
253+
items = [make_item(), make_item()]
254+
try:
255+
with _patch_service_method(method_name, response_attr, items):
256+
resp = _client("production_agent").post(path, json=payload)
257+
assert resp.status_code == 200
258+
finally:
259+
configure_usage_event_recorder(None)
260+
261+
applied = [e for e in events if e.event_name == "learning_applied"]
262+
assert len(applied) == 1
263+
assert applied[0].count_value == 2 # len(items) for this endpoint's response shape
264+
assert applied[0].caller_type == "production_agent"
265+
266+
267+
@pytest.mark.parametrize(
268+
("path", "payload", "method_name", "response_attr", "make_item"),
269+
_ENDPOINT_CASES,
270+
)
271+
def test_dashboard_per_endpoint_meters_nothing(
272+
path: str,
273+
payload: dict,
274+
method_name: str,
275+
response_attr: str,
276+
make_item,
277+
) -> None:
278+
"""A dashboard caller never meters, regardless of the route or result size."""
279+
events = _capture()
280+
try:
281+
with _patch_service_method(method_name, response_attr, [make_item()]):
282+
resp = _client("dashboard").post(path, json=payload)
283+
assert resp.status_code == 200
284+
finally:
285+
configure_usage_event_recorder(None)
286+
287+
assert [e for e in events if e.event_name == "learning_applied"] == []
288+
289+
290+
@pytest.mark.parametrize(
291+
("path", "payload", "method_name", "response_attr", "make_item"),
292+
_ENDPOINT_CASES,
293+
)
294+
def test_empty_result_per_endpoint_meters_nothing(
295+
path: str,
296+
payload: dict,
297+
method_name: str,
298+
response_attr: str,
299+
make_item,
300+
) -> None:
301+
"""A production-agent call surfacing zero results meters nothing on any route."""
302+
events = _capture()
303+
try:
304+
with _patch_service_method(method_name, response_attr, []):
305+
resp = _client("production_agent").post(path, json=payload)
306+
assert resp.status_code == 200
307+
finally:
308+
configure_usage_event_recorder(None)
309+
310+
assert [e for e in events if e.event_name == "learning_applied"] == []

0 commit comments

Comments
 (0)