claude-smart 0.2.43 → 0.2.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/claude-smart.js +2 -2
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +21 -1
  8. package/plugin/pyproject.toml +2 -2
  9. package/plugin/scripts/backend-service.sh +50 -4
  10. package/plugin/scripts/cli.sh +2 -1
  11. package/plugin/scripts/ensure-plugin-root.sh +1 -0
  12. package/plugin/scripts/hook_entry.sh +5 -3
  13. package/plugin/scripts/smart-install.sh +2 -2
  14. package/plugin/src/README.md +57 -0
  15. package/plugin/uv.lock +126 -5
  16. package/plugin/vendor/reflexio/.env.example +9 -0
  17. package/plugin/vendor/reflexio/pyproject.toml +5 -2
  18. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +0 -1
  19. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +7 -4
  20. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +4 -4
  21. package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +2 -2
  22. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +13 -4
  23. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +3 -1
  24. package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +59 -6
  25. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  26. package/plugin/vendor/reflexio/reflexio/server/README.md +7 -1
  27. package/plugin/vendor/reflexio/reflexio/server/api.py +188 -34
  28. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +34 -0
  29. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +33 -11
  30. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +278 -29
  31. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +457 -181
  32. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +28 -0
  33. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +22 -12
  34. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +137 -9
  35. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +1 -1
  36. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +36 -3
  37. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +13 -3
  38. package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +17 -0
  39. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +1 -1
  40. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.1.prompt.md +69 -0
  41. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +71 -0
  42. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +27 -23
  43. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.2.prompt.md +234 -0
  44. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +244 -0
  45. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +19 -9
  46. package/plugin/vendor/reflexio/reflexio/server/services/README.md +58 -0
  47. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +1 -5
  48. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +44 -2
  49. package/plugin/vendor/reflexio/reflexio/server/services/embedding_text.py +62 -0
  50. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +8 -1
  51. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +91 -24
  52. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +3 -1
  53. package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +6 -3
  54. package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +1 -1
  55. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +18 -5
  56. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +2 -2
  57. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +88 -3
  58. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +36 -5
  59. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +6 -3
  60. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +6 -3
  61. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +32 -22
  62. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +89 -4
  63. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +46 -1
  64. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +12 -0
  65. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +1 -1
  66. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +8 -4
@@ -43,6 +43,12 @@ logger = logging.getLogger(__name__)
43
43
  FINISH_EXTRACTION_TOOL_NAME = "finish_extraction"
44
44
  PROFILE_EXTRACTOR_KIND = "profile"
45
45
 
46
+ # Max in-process retries when the model returns a plain-text turn with no tool
47
+ # call (finished_reason="no_tool_call"). tool_choice="required" is not always
48
+ # honored by every provider/model, so a single bad turn would otherwise drop the
49
+ # extraction entirely. Retries re-issue the same prompt from scratch.
50
+ _NO_TOOL_CALL_MAX_RETRIES = 2
51
+
46
52
 
47
53
  def _record_agent_usage_event(
48
54
  *,
@@ -351,18 +357,6 @@ class ResumableExtractionAgent:
351
357
  extra_tool_context: Any | None = None,
352
358
  log_label: str | None = None,
353
359
  ) -> AgentRunResult:
354
- finish_ctx = _FinishExtractionContext()
355
- ctx: Any = (
356
- _ExtractionAgentToolContext(
357
- finish_context=finish_ctx,
358
- extra_tool_context=extra_tool_context,
359
- )
360
- if extra_tool_context is not None
361
- else finish_ctx
362
- )
363
- registry = ToolRegistry(
364
- [*(extra_tools or []), create_finish_extraction_tool(output_schema)]
365
- )
366
360
  max_steps = self.max_steps
367
361
  if run.max_steps_remaining is not None:
368
362
  max_steps = min(max_steps, max(0, run.max_steps_remaining))
@@ -387,29 +381,101 @@ class ResumableExtractionAgent:
387
381
  else "required"
388
382
  )
389
383
 
390
- result = run_tool_loop(
391
- client=self.client,
392
- messages=messages,
393
- registry=registry,
394
- model_role=self.model_role,
395
- max_steps=max_steps,
396
- ctx=ctx,
397
- finish_tool_name=FINISH_EXTRACTION_TOOL_NAME,
398
- tool_choice=tool_choice,
399
- log_label=log_label,
400
- )
384
+ # Retry only the no_tool_call termination: the model emitted plain text
385
+ # with zero tool calls, so nothing was committed and re-issuing the same
386
+ # prompt is safe. Each attempt uses a fresh finish_ctx/registry so no
387
+ # partial state leaks across attempts. We do NOT retry once the model has
388
+ # already registered async tool calls (e.g. attach_pending_info_request)
389
+ # — restarting from scratch would discard them.
390
+ for attempt in range(_NO_TOOL_CALL_MAX_RETRIES + 1):
391
+ finish_ctx = _FinishExtractionContext()
392
+ ctx: Any = (
393
+ _ExtractionAgentToolContext(
394
+ finish_context=finish_ctx,
395
+ extra_tool_context=extra_tool_context,
396
+ )
397
+ if extra_tool_context is not None
398
+ else finish_ctx
399
+ )
400
+ registry = ToolRegistry(
401
+ [*(extra_tools or []), create_finish_extraction_tool(output_schema)]
402
+ )
403
+
404
+ result = run_tool_loop(
405
+ client=self.client,
406
+ messages=messages,
407
+ registry=registry,
408
+ model_role=self.model_role,
409
+ max_steps=max_steps,
410
+ ctx=ctx,
411
+ finish_tool_name=FINISH_EXTRACTION_TOOL_NAME,
412
+ tool_choice=tool_choice,
413
+ log_label=log_label,
414
+ )
415
+
416
+ should_retry = (
417
+ result.finished_reason == "no_tool_call"
418
+ and not result.pending_tool_call_ids
419
+ and attempt < _NO_TOOL_CALL_MAX_RETRIES
420
+ )
421
+ if not should_retry:
422
+ break
423
+ logger.warning(
424
+ "event=extraction_agent_retry org_id=%s user_id=%s "
425
+ "extractor_kind=%s run_id=%s request_id=%s "
426
+ "finished_reason=%s attempt=%d max_retries=%d",
427
+ run.binding.org_id,
428
+ run.binding.user_id,
429
+ run.binding.extractor_kind,
430
+ run.id,
431
+ run.binding.request_id,
432
+ result.finished_reason,
433
+ attempt + 1,
434
+ _NO_TOOL_CALL_MAX_RETRIES,
435
+ )
436
+ _record_agent_usage_event(
437
+ run=run,
438
+ event_name="extraction_agent_retry",
439
+ error_kind=result.finished_reason,
440
+ metadata={"attempt": attempt + 1},
441
+ )
401
442
 
402
443
  committed_output = (
403
444
  finish_ctx.output.model_dump() if finish_ctx.output is not None else None
404
445
  )
446
+ active_statuses = (AgentRunStatus.RUNNING, AgentRunStatus.RESUMING)
405
447
  if result.finished_reason == "finish_tool" and committed_output is not None:
406
- self.storage.update_agent_run_status(
448
+ stored_run = self.storage.update_agent_run_status(
407
449
  run.id,
408
450
  AgentRunStatus.AGENT_COMPLETED,
409
451
  committed_output=committed_output,
410
452
  pending_tool_call_ids=result.pending_tool_call_ids,
411
453
  max_steps_remaining=result.max_steps_remaining,
454
+ expected_statuses=active_statuses,
412
455
  )
456
+ if (
457
+ stored_run is None
458
+ or stored_run.status != AgentRunStatus.AGENT_COMPLETED
459
+ ):
460
+ logger.warning(
461
+ "event=extraction_agent_late_output_discarded org_id=%s "
462
+ "user_id=%s extractor_kind=%s run_id=%s request_id=%s "
463
+ "stored_status=%s",
464
+ run.binding.org_id,
465
+ run.binding.user_id,
466
+ run.binding.extractor_kind,
467
+ run.id,
468
+ run.binding.request_id,
469
+ stored_run.status if stored_run is not None else None,
470
+ )
471
+ return AgentRunResult(
472
+ run_id=run.id,
473
+ output=None,
474
+ pending_tool_call_ids=[],
475
+ messages=result.messages,
476
+ trace=result.trace,
477
+ finished_reason="late_output_discarded",
478
+ )
413
479
  logger.info(
414
480
  "event=extraction_agent_finished org_id=%s user_id=%s "
415
481
  "extractor_kind=%s run_id=%s request_id=%s "
@@ -437,6 +503,7 @@ class ResumableExtractionAgent:
437
503
  AgentRunStatus.FAILED,
438
504
  max_steps_remaining=result.max_steps_remaining,
439
505
  last_error=last_error,
506
+ expected_statuses=active_statuses,
440
507
  )
441
508
  logger.warning(
442
509
  "event=extraction_agent_failed org_id=%s user_id=%s "
@@ -161,7 +161,9 @@ def _select_current_extractor_config(
161
161
  f"Unsupported extractor kind {run.binding.extractor_kind!r}"
162
162
  )
163
163
 
164
- if config is not None and isinstance(config, ProfileExtractorConfig | PlaybookConfig):
164
+ if config is not None and isinstance(
165
+ config, ProfileExtractorConfig | PlaybookConfig
166
+ ):
165
167
  return config
166
168
  raise ResumeWorkerError(
167
169
  f"Current extractor config not found for {run.binding.extractor_kind}"
@@ -34,9 +34,12 @@ def get_extractor_name(config: object) -> str:
34
34
  return SINGLETON_USER_PLAYBOOK_NAME
35
35
  if isinstance(config, AgentSuccessConfig):
36
36
  return SINGLETON_AGENT_SUCCESS_EVALUATION_NAME
37
- return getattr(config, "extractor_name", None) or getattr(
38
- config, "playbook_name", None
39
- ) or getattr(config, "evaluation_name", "unknown") or "unknown"
37
+ return (
38
+ getattr(config, "extractor_name", None)
39
+ or getattr(config, "playbook_name", None)
40
+ or getattr(config, "evaluation_name", "unknown")
41
+ or "unknown"
42
+ )
40
43
 
41
44
 
42
45
  def filter_extractor_configs[TExtractorConfig](
@@ -23,7 +23,7 @@ def get_extractor_window_params[TExtractorConfig](
23
23
  Get effective window_size and stride_size for a specific extractor.
24
24
 
25
25
  Uses extractor's override values if set, otherwise falls back to global values,
26
- then to defaults (window_size=10, stride_size=5).
26
+ then to defaults (DEFAULT_WINDOW_SIZE / DEFAULT_STRIDE_SIZE from config_schema).
27
27
 
28
28
  Args:
29
29
  extractor_config: Extractor configuration object
@@ -172,10 +172,20 @@ class GenerationService:
172
172
  )
173
173
 
174
174
  try:
175
- # Always generate a new UUID for request_id
176
- request_id = str(uuid.uuid4())
175
+ caller_request_id = publish_user_interaction_request.request_id
176
+ request_id = (
177
+ caller_request_id
178
+ if caller_request_id is not None
179
+ else str(uuid.uuid4())
180
+ )
177
181
  result.request_id = request_id
178
182
 
183
+ if (
184
+ caller_request_id is not None
185
+ and self.storage.get_request(request_id) is not None # type: ignore[reportOptionalMemberAccess]
186
+ ):
187
+ raise ValueError(f"request_id {request_id!r} already exists")
188
+
179
189
  new_interactions: list[Interaction] = (
180
190
  GenerationService.get_interaction_from_publish_user_interaction_request(
181
191
  publish_user_interaction_request, request_id
@@ -326,7 +336,8 @@ class GenerationService:
326
336
  error_type=type(e).__name__,
327
337
  ):
328
338
  logger.exception(
329
- "Generation service failed for request %s", request_id,
339
+ "Generation service failed for request %s",
340
+ request_id,
330
341
  )
331
342
  result.warnings.append(msg)
332
343
  finally:
@@ -380,7 +391,8 @@ class GenerationService:
380
391
  error_type=type(e).__name__,
381
392
  ):
382
393
  logger.exception(
383
- "Failed to refresh user profile for user id: %s", user_id,
394
+ "Failed to refresh user profile for user id: %s",
395
+ user_id,
384
396
  )
385
397
  raise
386
398
 
@@ -521,7 +533,8 @@ class GenerationService:
521
533
  error_type=type(e).__name__,
522
534
  ):
523
535
  logger.exception(
524
- "Failed to cleanup retention target %s", target_name,
536
+ "Failed to cleanup retention target %s",
537
+ target_name,
525
538
  )
526
539
  finally:
527
540
  mgr.release_simple_lock()
@@ -403,7 +403,7 @@ class OperationStateManager:
403
403
  request when the lock is held by someone else. Required for the
404
404
  rerun loop to operate on the SAME interactions the original
405
405
  publish enqueued, not whatever the bookmark currently points at
406
- (R2 / reflexio-enterprise#59).
406
+ (R2).
407
407
 
408
408
  Returns:
409
409
  bool: True if lock acquired (proceed with generation), False if skipped
@@ -545,7 +545,7 @@ class OperationStateManager:
545
545
  FIFO drain — preserves order so blocked publishes are processed in the
546
546
  order they arrived. Replaces the older single-slot ``pending_request_id``
547
547
  last-wins behaviour that silently dropped earlier blocked publishes
548
- (R2 / reflexio-enterprise#59).
548
+ (R2).
549
549
 
550
550
  On a legacy state row (written by a pre-fix server before redeploy)
551
551
  the queue is missing but ``pending_request_id`` may be set; we surface
@@ -33,6 +33,66 @@ logger = logging.getLogger(__name__)
33
33
  # ===============================
34
34
 
35
35
 
36
+ def _coerce_existing_position(value: object) -> int:
37
+ """Accept either a bare int position or an ``"EXISTING-N"`` label.
38
+
39
+ Wired ONLY to ``UnifyDecision.archive_existing_ids`` — the one field whose
40
+ int contract is genuinely a list **position** (resolved via
41
+ ``existing_by_position`` as ``f"EXISTING-{idx}"`` in ``_apply_unify``).
42
+ ``RejectNewDecision.superseded_by_existing_id`` and
43
+ ``DifferentiateDecision.existing_id`` are DB ``user_playbook_id`` values
44
+ (resolved via ``existing_by_id`` in ``_apply_reject_new`` /
45
+ ``_apply_differentiate``) — coercing ``"EXISTING-N"`` to ``N`` on those
46
+ fields would silently misroute decisions when the position int doesn't
47
+ map to a real DB id.
48
+
49
+ The consolidation prompt labels rows as ``[EXISTING-0]``, ``[EXISTING-1]``
50
+ etc. (see ``_format_playbooks_with_prefix``) and ``_apply_unify``
51
+ reconstructs ``f"EXISTING-{position}"`` from the integer the LLM returns.
52
+ Strong structured-output models (GPT-4o, Claude) honor the ``list[int]``
53
+ schema and return the bare integer ``0``; weaker models (e.g. MiniMax-M3)
54
+ ignore the int constraint and return the literal label ``"EXISTING-0"``
55
+ instead — which then fails pydantic validation and the whole
56
+ consolidation batch dies.
57
+
58
+ Strip the prefix when present so the schema tolerates both shapes
59
+ without changing the int contract downstream consumers rely on. Plain
60
+ numeric strings (``"5"``) are also accepted for symmetry with how
61
+ most JSON-coerced models handle ID-like values. Negative values are
62
+ rejected — list positions are always ``>= 0``.
63
+
64
+ Raises:
65
+ ValueError: when ``value`` is not a non-negative int or a recognized
66
+ position-label / numeric string.
67
+ """
68
+ if isinstance(value, bool):
69
+ # ``bool`` is a subclass of ``int`` in Python; reject explicitly so a
70
+ # stray ``True`` doesn't silently become position 1.
71
+ raise ValueError(f"existing-position must be int, got bool: {value!r}")
72
+ if isinstance(value, int):
73
+ if value < 0:
74
+ raise ValueError(f"existing-position must be >= 0, got {value!r}")
75
+ return value
76
+ if isinstance(value, str):
77
+ stripped = value.strip()
78
+ for prefix in ("EXISTING-", "EXISTING_", "existing-", "existing_"):
79
+ if stripped.startswith(prefix):
80
+ stripped = stripped[len(prefix) :]
81
+ break
82
+ try:
83
+ parsed = int(stripped)
84
+ except ValueError as exc:
85
+ raise ValueError(
86
+ f"existing-position must be int or 'EXISTING-N' label, got {value!r}"
87
+ ) from exc
88
+ if parsed < 0:
89
+ raise ValueError(f"existing-position must be >= 0, got {value!r}")
90
+ return parsed
91
+ raise ValueError(
92
+ f"existing-position must be int or 'EXISTING-N' label, got {type(value).__name__}: {value!r}"
93
+ )
94
+
95
+
36
96
  class UnifyDecision(BaseModel):
37
97
  """Collapse NEW (+ 0..N EXISTING) into one row with LLM-supplied content.
38
98
 
@@ -57,11 +117,28 @@ class UnifyDecision(BaseModel):
57
117
  rationale: str
58
118
  reason: str = ""
59
119
 
120
+ @field_validator("archive_existing_ids", mode="before")
121
+ @classmethod
122
+ def _coerce_archive_ids(cls, value: object) -> object:
123
+ if value is None:
124
+ return []
125
+ if isinstance(value, list):
126
+ return [_coerce_existing_position(item) for item in value]
127
+ return value
128
+
60
129
  model_config = ConfigDict(json_schema_extra={"additionalProperties": False})
61
130
 
62
131
 
63
132
  class RejectNewDecision(BaseModel):
64
- """The new candidate is redundant; an existing row supersedes it (storage no-op)."""
133
+ """The new candidate is redundant; an existing row supersedes it (storage no-op).
134
+
135
+ ``superseded_by_existing_id`` is a DB ``user_playbook_id`` (resolved
136
+ against ``existing_by_id`` in ``_apply_reject_new``), NOT a list
137
+ position — no ``"EXISTING-N"`` coercion is wired here. If the LLM
138
+ returns ``"EXISTING-N"`` for this field, pydantic rejects it loudly
139
+ rather than silently misrouting the decision (see
140
+ ``_coerce_existing_position`` docstring).
141
+ """
65
142
 
66
143
  kind: Literal["reject_new"] = "reject_new"
67
144
  new_id: str
@@ -72,7 +149,13 @@ class RejectNewDecision(BaseModel):
72
149
 
73
150
 
74
151
  class DifferentiateDecision(BaseModel):
75
- """Both rules valid in distinct contexts: refine both triggers."""
152
+ """Both rules valid in distinct contexts: refine both triggers.
153
+
154
+ ``existing_id`` is a DB ``user_playbook_id`` (resolved against
155
+ ``existing_by_id`` in ``_apply_differentiate``), NOT a list position —
156
+ no ``"EXISTING-N"`` coercion is wired here. See
157
+ ``_coerce_existing_position`` docstring.
158
+ """
76
159
 
77
160
  kind: Literal["differentiate"] = "differentiate"
78
161
  new_id: str
@@ -571,7 +654,9 @@ class PlaybookConsolidator(BaseDeduplicator):
571
654
  ):
572
655
  logger.exception(
573
656
  "event=consolidation_apply_failed kind=%s new_id=%s existing_id=%s",
574
- decision.kind, new_id_str, existing_id_str,
657
+ decision.kind,
658
+ new_id_str,
659
+ existing_id_str,
575
660
  )
576
661
  continue
577
662
  new_rows.extend(rows)
@@ -7,12 +7,12 @@ import logging
7
7
  import os
8
8
  import uuid
9
9
  from datetime import UTC, datetime
10
+ from typing import Any, cast
10
11
 
11
12
  from pydantic import BaseModel, ConfigDict, Field
12
13
 
13
14
  from reflexio.models.api_schema.retriever_schema import SearchUserProfileRequest
14
15
  from reflexio.models.api_schema.service_schemas import Status, UserProfile
15
- from reflexio.models.config_schema import EMBEDDING_DIMENSIONS
16
16
  from reflexio.server.api_endpoints.request_context import RequestContext
17
17
  from reflexio.server.llm.litellm_client import LiteLLMClient
18
18
  from reflexio.server.services.deduplication_utils import (
@@ -20,6 +20,7 @@ from reflexio.server.services.deduplication_utils import (
20
20
  format_dedup_timestamp,
21
21
  parse_item_id,
22
22
  )
23
+ from reflexio.server.services.embedding_text import embedding_input
23
24
  from reflexio.server.services.profile.profile_generation_service_utils import (
24
25
  ProfileTimeToLive,
25
26
  calculate_expiration_timestamp,
@@ -302,11 +303,41 @@ class ProfileDeduplicator(BaseDeduplicator):
302
303
  if not query_texts:
303
304
  return []
304
305
 
305
- # Batch-generate embeddings
306
+ # Generate embeddings with the request storage backend so dedup search
307
+ # uses the same model/prefix/routing as normal profile search.
308
+ embeddings: list[list[float] | None]
306
309
  try:
307
- embeddings = self.client.get_embeddings(
308
- query_texts, dimensions=EMBEDDING_DIMENSIONS
309
- )
310
+ get_storage_embedding = getattr(storage, "_get_embedding", None)
311
+ if callable(get_storage_embedding):
312
+ logger.info(
313
+ "Profile dedup query embeddings: source=storage model=%s",
314
+ getattr(storage, "embedding_model_name", "unknown"),
315
+ )
316
+ embeddings = [
317
+ cast(
318
+ list[float],
319
+ get_storage_embedding(query_text, purpose="query"),
320
+ )
321
+ for query_text in query_texts
322
+ ]
323
+ else:
324
+ storage_with_embeddings = cast(Any, storage)
325
+ embedding_model_name = storage_with_embeddings.embedding_model_name
326
+ embedding_dimensions = storage_with_embeddings.embedding_dimensions
327
+ logger.info(
328
+ "Profile dedup query embeddings: source=llm_client model=%s",
329
+ embedding_model_name,
330
+ )
331
+ embeddings = list(
332
+ self.client.get_embeddings(
333
+ [
334
+ embedding_input(query_text, purpose="query")
335
+ for query_text in query_texts
336
+ ],
337
+ model=embedding_model_name,
338
+ dimensions=embedding_dimensions,
339
+ )
340
+ )
310
341
  except Exception as e:
311
342
  logger.warning("Failed to generate embeddings for dedup search: %s", e)
312
343
  embeddings = [None] * len(query_texts)
@@ -198,7 +198,8 @@ class ProfileGenerationService(
198
198
  error_type=type(e).__name__,
199
199
  ):
200
200
  logger.exception(
201
- "Failed to save profiles for user id: %s", user_id,
201
+ "Failed to save profiles for user id: %s",
202
+ user_id,
202
203
  )
203
204
  return
204
205
 
@@ -224,7 +225,8 @@ class ProfileGenerationService(
224
225
  ):
225
226
  logger.exception(
226
227
  "Failed to delete superseded profile %s for user %s",
227
- profile_id, user_id,
228
+ profile_id,
229
+ user_id,
228
230
  )
229
231
 
230
232
  # Create profile changelog post-deduplication
@@ -250,7 +252,8 @@ class ProfileGenerationService(
250
252
  error_type=type(e).__name__,
251
253
  ):
252
254
  logger.exception(
253
- "Failed to add profile change log for user %s", user_id,
255
+ "Failed to add profile change log for user %s",
256
+ user_id,
254
257
  )
255
258
 
256
259
  def check_and_update_profiles(self, profiles: list[UserProfile]) -> None:
@@ -253,7 +253,8 @@ class ReflectionService:
253
253
  ):
254
254
  logger.exception(
255
255
  "event=reflection_apply_failed kind=%s target_id=%s",
256
- decision.target_kind, decision.target_id,
256
+ decision.target_kind,
257
+ decision.target_id,
257
258
  )
258
259
  continue
259
260
  if applied:
@@ -498,7 +499,8 @@ class ReflectionService:
498
499
  logger.exception(
499
500
  "event=reflection_archive_after_insert_failed kind=profile "
500
501
  "cited_id=%s new_id=%s",
501
- cited.profile_id, new_profile.profile_id,
502
+ cited.profile_id,
503
+ new_profile.profile_id,
502
504
  )
503
505
  return True
504
506
  if not archived:
@@ -597,7 +599,8 @@ class ReflectionService:
597
599
  logger.exception(
598
600
  "event=reflection_archive_after_insert_failed kind=playbook "
599
601
  "cited_id=%s new_id=%s",
600
- cited.user_playbook_id, new_playbook.user_playbook_id,
602
+ cited.user_playbook_id,
603
+ new_playbook.user_playbook_id,
601
604
  )
602
605
  return True
603
606
  if not archived:
@@ -15,6 +15,7 @@ from reflexio.server.llm.rerank import score_pairs
15
15
  from reflexio.server.llm.rerank.cross_encoder_reranker import (
16
16
  CrossEncoderUnavailableError,
17
17
  )
18
+ from reflexio.server.tracing import profile_step
18
19
 
19
20
  logger = logging.getLogger(__name__)
20
21
 
@@ -44,27 +45,36 @@ def apply_relevance_floor[T](
44
45
  """
45
46
  if not items:
46
47
  return []
47
- try:
48
- scores = score_pairs(query, [content_of(item) for item in items])
49
- except CrossEncoderUnavailableError:
50
- logger.warning(
51
- "event=relevance_floor_unavailable arm=%s items=%d (returning unfiltered top_k)",
52
- arm,
53
- len(items),
54
- )
55
- return items[:top_k]
48
+ with profile_step(
49
+ "search.rerank.relevance_floor",
50
+ arm=arm,
51
+ items=len(items),
52
+ ) as span:
53
+ try:
54
+ scores = score_pairs(query, [content_of(item) for item in items])
55
+ except CrossEncoderUnavailableError:
56
+ span.set_data("available", False)
57
+ logger.warning(
58
+ "event=relevance_floor_unavailable arm=%s items=%d (returning unfiltered top_k)",
59
+ arm,
60
+ len(items),
61
+ )
62
+ return items[:top_k]
63
+ span.set_data("available", True)
56
64
 
57
- ranked = sorted(
58
- zip(items, scores, strict=True), key=lambda pair: pair[1], reverse=True
59
- )
60
- survivors = [item for item, score in ranked if score >= floor]
61
- dropped = len(ranked) - len(survivors)
62
- if dropped:
63
- logger.info(
64
- "event=relevance_floor arm=%s kept=%d dropped=%d floor=%.2f",
65
- arm,
66
- len(survivors),
67
- dropped,
68
- floor,
65
+ ranked = sorted(
66
+ zip(items, scores, strict=True), key=lambda pair: pair[1], reverse=True
69
67
  )
70
- return survivors[:top_k]
68
+ survivors = [item for item, score in ranked if score >= floor]
69
+ dropped = len(ranked) - len(survivors)
70
+ span.set_data("kept", len(survivors))
71
+ span.set_data("dropped", dropped)
72
+ if dropped:
73
+ logger.info(
74
+ "event=relevance_floor arm=%s kept=%d dropped=%d floor=%.2f",
75
+ arm,
76
+ len(survivors),
77
+ dropped,
78
+ floor,
79
+ )
80
+ return survivors[:top_k]