Skip to content

Commit ec99c53

Browse files
fix: judge short sessions on completion or inactivity (#659)
Short sessions can be saved without reaching the first extraction window or stride. Add optional `session_finished` publishing and `finalize_session` operations, plus a default six-hour session inactivity fallback. Both reuse the existing judge → extractor → consolidation → conditional aggregation pipeline. Normal window/stride and explicit manual-force behavior stay intact. Boundaries survive late messages, respect project/user scope, ignore covered or erased prefixes, and retain durable wakeups across restart. Existing pinned work remains immutable. Timeout configuration is captured prospectively; zero disables fallback for new publications. Legacy admissions support explicit completion without inferred idle backfill. Validation: 443 backend/SDK tests passed after rebasing onto current main, covering real HTTP/provider seams, positive/negative judges, retries, rollback, late arrivals, erasure, index use and scheduler restart. Ruff/Pyright passed. Follow-up review fix invalidates learning caches on successful sync/async completion; 90 SDK/cache tests passed. All four current-head installed-wheel CI jobs passed. GitHub Codex reviewed the current head with no major issues and no P0/P1 findings. Related PRs: - PostgreSQL integration and public documentation: ReflexioAI/reflexio-enterprise#1765 - Plugin: ReflexioAI/claude-smart#189 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Partial extraction windows can now be evaluated after a session-completion hint or a configurable inactivity period, while retaining the usefulness check. * Added session-finalization methods for standard and session-scoped clients. * Short messages can now reach the usefulness check instead of being rejected by a length filter. * **Documentation** * Updated guidance on completion hints, idle timeouts, partial windows, and manual forced extraction. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Completion acceptance also survives post-commit scheduler failures. Five HTTP/library short-session regression cases passed; Ruff and Pyright passed. GitHub Codex review completed on the final head with no major issues and no P0/P1 findings.
1 parent 8771081 commit ec99c53

17 files changed

Lines changed: 648 additions & 115 deletions

File tree

‎README.md‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,28 @@ Publishing commits interactions before returning success. Background workers pro
471471
configured sliding windows in admission order, regardless of request boundaries.
472472
Different users can extract concurrently within `REFLEXIO_PUBLISH_LEARNING_WORKERS`
473473
(default 4 per process); each user's profile/playbook cursors retry independently.
474-
A partial window waits for more input or an eligible `force_extraction` request.
474+
A partial window is evaluated after an optional completion hint or six hours of
475+
session inactivity by default. Both paths retain the usefulness judge; a negative
476+
judge result covers the input without generating learnings. Normal full windows
477+
and strides run as before. Later messages in a finalized session remain valid.
478+
479+
```python
480+
client.publish_interaction(user_id="u", session_id="s", interactions=messages,
481+
session_finished=True)
482+
# Or signal completion after messages were already saved:
483+
client.finalize_session(user_id="u", session_id="s")
484+
```
485+
486+
Completion returns acceptance, not extraction completion. The async and
487+
`client.for_session("s")` APIs support the same operation. Set Config's
488+
`extraction_idle_timeout_seconds` (default `21600`; `0` disables fallback for new
489+
publications). This is a configurable low-urgency policy, not proof that a session
490+
ended. Activity is scoped by organization/project/user/session; other sessions do
491+
not postpone its deadline. Each publication captures its timeout, so edits apply
492+
to subsequent publications. Existing pre-upgrade admissions have no inferred
493+
idle deadline; explicitly finalize those surviving sessions when needed.
494+
The library scheduler needs a live process and reachable `Reflexio` handle;
495+
reopening resumes durable deadlines without another publish.
475496
Provider fallback remains enabled. Existing extracted data is preserved, and
476497
interactions stored before stream admission are not automatically replayed.
477498

@@ -489,9 +510,9 @@ or waiting for input, reporting `learning_status="deferred"` with
489510
`learning_reason="waiting_for_window"`. Poll `GET /api/learning_status` with the
490511
returned `request_id` to see coverage complete later. Note the practical
491512
consequence of the default `window_size` of 10: a brand-new user's first window
492-
needs ten eligible interactions before anything is extracted, so a single publish
493-
legitimately returns uncovered. Pass `force_extraction` when you need that publish
494-
extracted on its own.
513+
normally needs ten eligible interactions, so an incomplete first publish returns
514+
uncovered until more input, an optional completion hint, or its idle deadline.
515+
Reserve `force_extraction` for an explicit manual bypass of the judge.
495516

496517
### Nomic dependency compatibility
497518

‎reflexio/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
DeleteUserPlaybooksByIdsRequest,
5858
DeleteUserProfileRequest,
5959
DeleteUserProfileResponse,
60+
FinalizeSessionRequest,
61+
FinalizeSessionResponse,
6062
Interaction,
6163
InteractionData,
6264
PlaybookStatus,
@@ -149,6 +151,8 @@
149151
"InteractionData",
150152
"PlaybookStatus",
151153
"ProfileTimeToLive",
154+
"FinalizeSessionRequest",
155+
"FinalizeSessionResponse",
152156
"PublishUserInteractionRequest",
153157
"PublishUserInteractionResponse",
154158
"RerunPlaybookGenerationRequest",
@@ -214,6 +218,8 @@
214218
"EvaluationResultView",
215219
"ProfileChangeLogView",
216220
# Request types
221+
"FinalizeSessionRequest",
222+
"FinalizeSessionResponse",
217223
"PublishUserInteractionRequest",
218224
"DeleteUserProfileRequest",
219225
"DeleteUserInteractionRequest",

‎reflexio/client/client.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@
124124
DeleteUserPlaybooksByIdsRequest,
125125
DeleteUserProfileRequest,
126126
DeleteUserProfileResponse,
127+
FinalizeSessionRequest,
128+
FinalizeSessionResponse,
127129
GetOperationStatusResponse,
128130
GetSessionOutcomesRequest,
129131
GetSessionOutcomesResponse,
@@ -457,6 +459,40 @@ def _make_request(
457459
"Is REFLEXIO_URL pointing at the API host?"
458460
) from exc
459461

462+
def finalize_session(
463+
self, user_id: str, session_id: str
464+
) -> FinalizeSessionResponse:
465+
"""Accept an optional completion hint for saved messages.
466+
467+
Returns acceptance, not extraction completion. The judge remains
468+
enabled, and the session can receive later publications.
469+
"""
470+
request = FinalizeSessionRequest(user_id=user_id, session_id=session_id)
471+
result = FinalizeSessionResponse(
472+
**self._make_request(
473+
"POST", "/api/finalize_session", json=request.model_dump(mode="json")
474+
)
475+
)
476+
if result.success:
477+
self._cache.invalidate("get_profiles")
478+
self._cache.invalidate("get_agent_playbooks")
479+
return result
480+
481+
async def finalize_session_async(
482+
self, user_id: str, session_id: str
483+
) -> FinalizeSessionResponse:
484+
"""Native-async counterpart to :meth:`finalize_session`."""
485+
request = FinalizeSessionRequest(user_id=user_id, session_id=session_id)
486+
result = FinalizeSessionResponse(
487+
**await self._make_async_request(
488+
"POST", "/api/finalize_session", json=request.model_dump(mode="json")
489+
)
490+
)
491+
if result.success:
492+
self._cache.invalidate("get_profiles")
493+
self._cache.invalidate("get_agent_playbooks")
494+
return result
495+
460496
def _publish_interaction_sync(
461497
self,
462498
request: PublishUserInteractionRequest,
@@ -511,6 +547,7 @@ def _build_publish_interaction_request(
511547
override_learning_stall: bool,
512548
retrieval_experiment_id: str | None,
513549
retrieval_experiment_arm: Literal["treatment", "holdout"] | None,
550+
session_finished: bool = False,
514551
) -> PublishUserInteractionRequest:
515552
"""Validate and build the request shared by sync and async publishing."""
516553
if session_id is None or not session_id.strip():
@@ -531,6 +568,7 @@ def _build_publish_interaction_request(
531568
agent_version=agent_version,
532569
skip_aggregation=skip_aggregation,
533570
force_extraction=force_extraction,
571+
session_finished=session_finished,
534572
evaluation_only=evaluation_only,
535573
override_learning_stall=override_learning_stall,
536574
retrieval_experiment_id=retrieval_experiment_id,
@@ -562,6 +600,7 @@ def publish_interaction(
562600
override_learning_stall: bool = False,
563601
retrieval_experiment_id: str | None = None,
564602
retrieval_experiment_arm: Literal["treatment", "holdout"] | None = None,
603+
session_finished: bool = False,
565604
) -> PublishUserInteractionResponse:
566605
"""Publish user interactions.
567606
@@ -598,6 +637,8 @@ def publish_interaction(
598637
force_extraction: If True, bypass all extraction gates
599638
(stride_size, cheap pre-filter, LLM should_run) and
600639
always run extractors.
640+
session_finished: Optional completion hint; evaluates the saved prefix
641+
through the judge without waiting for a full window. Later messages remain valid.
601642
evaluation_only: If True, store the interaction for
602643
session-level evaluation only and permanently exclude it
603644
from profile/playbook extraction.
@@ -639,6 +680,7 @@ def publish_interaction(
639680
session_id=session_id,
640681
skip_aggregation=skip_aggregation,
641682
force_extraction=force_extraction,
683+
session_finished=session_finished,
642684
evaluation_only=evaluation_only,
643685
override_learning_stall=override_learning_stall,
644686
retrieval_experiment_id=retrieval_experiment_id,
@@ -669,6 +711,7 @@ async def publish_interaction_async(
669711
override_learning_stall: bool = False,
670712
retrieval_experiment_id: str | None = None,
671713
retrieval_experiment_arm: Literal["treatment", "holdout"] | None = None,
714+
session_finished: bool = False,
672715
) -> PublishUserInteractionResponse:
673716
"""Native-async counterpart to :meth:`publish_interaction`."""
674717
request = self._build_publish_interaction_request(
@@ -679,6 +722,7 @@ async def publish_interaction_async(
679722
session_id=session_id,
680723
skip_aggregation=skip_aggregation,
681724
force_extraction=force_extraction,
725+
session_finished=session_finished,
682726
evaluation_only=evaluation_only,
683727
override_learning_stall=override_learning_stall,
684728
retrieval_experiment_id=retrieval_experiment_id,
@@ -3724,6 +3768,20 @@ def __getattr__(self, name: str) -> Any:
37243768
)
37253769
return attribute
37263770

3771+
def finalize_session(
3772+
self, user_id: str, session_id: str | None = None
3773+
) -> FinalizeSessionResponse:
3774+
"""Finalize the bound session's saved prefix, retaining the judge."""
3775+
return self._client.finalize_session(user_id, self._resolve(session_id))
3776+
3777+
async def finalize_session_async(
3778+
self, user_id: str, session_id: str | None = None
3779+
) -> FinalizeSessionResponse:
3780+
"""Native-async completion hint for the bound session."""
3781+
return await self._client.finalize_session_async(
3782+
user_id, self._resolve(session_id)
3783+
)
3784+
37273785
# -- publishing -------------------------------------------------------
37283786

37293787
def publish_interaction(
@@ -3740,6 +3798,7 @@ def publish_interaction(
37403798
override_learning_stall: bool = False,
37413799
retrieval_experiment_id: str | None = None,
37423800
retrieval_experiment_arm: Literal["treatment", "holdout"] | None = None,
3801+
session_finished: bool = False,
37433802
) -> PublishUserInteractionResponse:
37443803
"""Publish interactions into the bound session.
37453804
@@ -3755,6 +3814,7 @@ def publish_interaction(
37553814
wait_for_response=wait_for_response,
37563815
skip_aggregation=skip_aggregation,
37573816
force_extraction=force_extraction,
3817+
session_finished=session_finished,
37583818
evaluation_only=evaluation_only,
37593819
override_learning_stall=override_learning_stall,
37603820
retrieval_experiment_id=retrieval_experiment_id,
@@ -3775,6 +3835,7 @@ async def publish_interaction_async(
37753835
override_learning_stall: bool = False,
37763836
retrieval_experiment_id: str | None = None,
37773837
retrieval_experiment_arm: Literal["treatment", "holdout"] | None = None,
3838+
session_finished: bool = False,
37783839
) -> PublishUserInteractionResponse:
37793840
"""Native-async counterpart to :meth:`publish_interaction`."""
37803841
return await self._client.publish_interaction_async(
@@ -3786,6 +3847,7 @@ async def publish_interaction_async(
37863847
wait_for_response=wait_for_response,
37873848
skip_aggregation=skip_aggregation,
37883849
force_extraction=force_extraction,
3850+
session_finished=session_finished,
37893851
evaluation_only=evaluation_only,
37903852
override_learning_stall=override_learning_stall,
37913853
retrieval_experiment_id=retrieval_experiment_id,

‎reflexio/lib/_interactions.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
DeleteSessionResponse,
2626
DeleteUserInteractionRequest,
2727
DeleteUserInteractionResponse,
28+
FinalizeSessionRequest,
29+
FinalizeSessionResponse,
2830
PublishUserInteractionRequest,
2931
PublishUserInteractionResponse,
3032
)
@@ -181,6 +183,46 @@ def publish_interaction(
181183
except Exception as e:
182184
return PublishUserInteractionResponse(success=False, message=str(e))
183185

186+
def finalize_session(
187+
self,
188+
request: FinalizeSessionRequest | dict,
189+
) -> FinalizeSessionResponse:
190+
"""Accept an optional completion hint without bypassing the judge.
191+
192+
The saved prefix is processed asynchronously. Later publications are
193+
accepted. A missing session is a successful no-op.
194+
"""
195+
if not self._is_storage_configured():
196+
return FinalizeSessionResponse(
197+
success=False, message=STORAGE_NOT_CONFIGURED_MSG
198+
)
199+
if isinstance(request, dict):
200+
request = FinalizeSessionRequest(**request)
201+
accepted = self._get_storage().finalize_session(
202+
request.user_id, request.session_id
203+
)
204+
from reflexio.server.services.durable_learning.local import (
205+
ensure_local_extraction,
206+
)
207+
208+
if accepted:
209+
try:
210+
ensure_local_extraction(self.request_context)
211+
except Exception as error:
212+
# The marker is durable; scheduler wakeup is best-effort.
213+
logger.warning(
214+
"event=session_finalization_wakeup_failed error_type=%s",
215+
type(error).__name__,
216+
exc_info=True,
217+
)
218+
return FinalizeSessionResponse(
219+
success=True,
220+
accepted=accepted,
221+
message="Completion hint accepted; learning is asynchronous."
222+
if accepted
223+
else "No saved session to finalize.",
224+
)
225+
184226
def search_interactions(
185227
self,
186228
request: SearchInteractionRequest | dict,

‎reflexio/models/api_schema/domain/entities.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@
8888
"ClearUserDataRequest",
8989
"ClearUserDataResponse",
9090
"InteractionData",
91+
"FinalizeSessionRequest",
92+
"FinalizeSessionResponse",
9193
"PublishUserInteractionRequest",
9294
"PublishUserInteractionResponse",
9395
"WhoamiResponse",
@@ -1611,6 +1613,9 @@ class PublishUserInteractionRequest(CapturesUnknownFields):
16111613
# this is used for aggregating interactions for generating agent playbooks
16121614
agent_version: str = Field(default="", max_length=1_000)
16131615
session_id: NonEmptyStr # used for grouping requests together
1616+
session_finished: bool = (
1617+
False # Finalize the saved prefix without bypassing the judge.
1618+
)
16141619
skip_aggregation: bool = (
16151620
False # when True, extract profiles/playbooks but skip aggregation
16161621
)
@@ -1771,6 +1776,21 @@ def validate_retrieved_learnings_total(self) -> Self:
17711776

17721777

17731778
# publish user interaction response
1779+
class FinalizeSessionRequest(BaseModel):
1780+
"""Optional completion hint for an authenticated user's saved session."""
1781+
1782+
user_id: NonEmptyStr
1783+
session_id: NonEmptyStr
1784+
1785+
1786+
class FinalizeSessionResponse(BaseModel):
1787+
"""Acceptance only; extraction continues asynchronously through its judge."""
1788+
1789+
success: bool
1790+
accepted: bool = False
1791+
message: str = ""
1792+
1793+
17741794
class PublishUserInteractionResponse(BaseModel):
17751795
success: bool
17761796
message: str = ""

‎reflexio/models/config_schema.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,8 @@ class Config(BaseModel):
924924
# extraction parameters
925925
window_size: int = Field(default=DEFAULT_WINDOW_SIZE, gt=0)
926926
stride_size: int = Field(default=DEFAULT_STRIDE_SIZE, gt=0)
927+
# Captured at admission; 0 disables idle finalization for new publications.
928+
extraction_idle_timeout_seconds: int = Field(default=21600, ge=0)
927929
# API key configuration for LLM providers
928930
api_key_config: APIKeyConfig | None = None
929931
# LLM model configuration overrides

‎reflexio/server/routes/interactions.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
DeleteSessionResponse,
3434
DeleteUserInteractionRequest,
3535
DeleteUserInteractionResponse,
36+
FinalizeSessionRequest,
37+
FinalizeSessionResponse,
3638
GetSessionOutcomesRequest,
3739
GetSessionOutcomesResponse,
3840
LearningStatusResponse,
@@ -60,6 +62,18 @@
6062
PUBLISH_REQUEST_TIMEOUT_SECONDS = 240.0
6163

6264

65+
@router.post("/api/finalize_session", response_model=FinalizeSessionResponse)
66+
@limiter.limit("60/minute")
67+
def finalize_session(
68+
request: Request,
69+
payload: FinalizeSessionRequest,
70+
org_id: str = Depends(default_get_org_id),
71+
_gate: None = Depends(default_billing_gate("learnings_generated")), # noqa: B008
72+
) -> FinalizeSessionResponse:
73+
"""Accept a completion hint; this does not wait for extraction."""
74+
return publisher_api.get_reflexio(org_id=org_id).finalize_session(payload)
75+
76+
6377
@router.post(
6478
"/api/session_outcome",
6579
response_model=SetSessionOutcomeResponse,

‎reflexio/server/services/base_generation/_should_run.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def _should_run_before_extraction(self, extractor_config: TExtractorConfig) -> b
159159
)
160160

161161
# Cheap pre-filter: apply user-text-only heuristics (slash-commands,
162-
# short turns, extractor-prompt echoes) without burning a 5–7s LLM
162+
# extractor-prompt echoes) without burning a 5–7s LLM
163163
# call. Batches with no non-empty user text bypass these heuristics;
164164
# stride/window eligibility was already evaluated independently.
165165
from reflexio.server.services.base_generation_service import (
@@ -176,7 +176,7 @@ def _should_run_before_extraction(self, extractor_config: TExtractorConfig) -> b
176176
)
177177
# Report WHICH heuristic rejected, not just that we skipped: the
178178
# caller's bare ``should_skip`` cannot distinguish "the cheap
179-
# pre-filter rejected on short/slash-command turns" from "the LLM
179+
# pre-filter rejected on slash-command turns" from "the LLM
180180
# gate voted no", and the cheap path never reaches the LLM so there
181181
# is no token spend to infer it from either.
182182
#

0 commit comments

Comments
 (0)