claude-smart 0.2.49 → 0.2.50

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 (177) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +10 -43
  3. package/bin/claude-smart.js +105 -0
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/pyproject.toml +1 -1
  27. package/plugin/scripts/_lib.sh +197 -1
  28. package/plugin/scripts/backend-python-runner.py +46 -0
  29. package/plugin/scripts/backend-service.sh +757 -119
  30. package/plugin/scripts/codex-hook.js +63 -225
  31. package/plugin/scripts/dashboard-open.sh +6 -4
  32. package/plugin/scripts/dashboard-service.sh +117 -136
  33. package/plugin/scripts/hook_entry.sh +3 -0
  34. package/plugin/scripts/smart-install.sh +15 -1
  35. package/plugin/src/claude_smart/cli.py +14 -0
  36. package/plugin/src/claude_smart/context_inject.py +3 -0
  37. package/plugin/src/claude_smart/env_config.py +4 -1
  38. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  39. package/plugin/src/claude_smart/events/session_end.py +2 -1
  40. package/plugin/src/claude_smart/events/stop.py +3 -0
  41. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  42. package/plugin/src/claude_smart/internal_call.py +5 -2
  43. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  44. package/plugin/src/claude_smart/publish.py +59 -7
  45. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  46. package/plugin/src/claude_smart/runtime.py +15 -6
  47. package/plugin/src/claude_smart/state.py +211 -52
  48. package/plugin/uv.lock +1 -1
  49. package/plugin/vendor/reflexio/.env.example +13 -0
  50. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  51. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  52. package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
  53. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  54. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  55. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  56. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
  57. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  58. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  59. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  60. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  61. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  62. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  63. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  64. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  65. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
  67. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  68. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  69. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  70. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  71. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
  72. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  73. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  74. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  75. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  76. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  77. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
  78. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  79. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  80. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  81. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  82. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  83. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  84. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  85. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  86. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  90. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  91. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  92. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  93. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  94. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  95. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  96. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  99. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  100. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  101. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  103. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  104. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  105. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  106. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
  109. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  110. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  111. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  112. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  113. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  114. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  115. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  116. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
  118. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  120. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  122. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  124. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  125. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  126. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  127. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  128. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  129. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  130. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  131. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  132. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  133. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
  134. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  135. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  139. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  172. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  173. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  174. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  175. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  176. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -89,6 +89,12 @@ class ClaudeCodeCLIError(RuntimeError):
89
89
  """Raised when the claude CLI subprocess fails in a way we cannot recover from."""
90
90
 
91
91
 
92
+ def _diagnostic_excerpt(text: str, limit: int = 500) -> str:
93
+ """Return a bounded single-line excerpt for local CLI diagnostics."""
94
+ compact = " ".join((text or "").split())
95
+ return compact[:limit]
96
+
97
+
92
98
  def _env_enabled() -> bool:
93
99
  """Return True when ``CLAUDE_SMART_USE_LOCAL_CLI`` is set to a truthy value.
94
100
 
@@ -1037,7 +1043,9 @@ class ClaudeCodeLLM(CustomLLM):
1037
1043
  self._record_stall_safely(result)
1038
1044
  raise ClaudeCodeCLIError(
1039
1045
  f"claude -p stream failed; retry_errors={result.retry_errors}; "
1040
- f"stderr={result.stderr_text[:200]!r}"
1046
+ f"stderr={_diagnostic_excerpt(result.stderr_text)!r}; "
1047
+ f"stdout={_diagnostic_excerpt(result.terminal_text)!r}; "
1048
+ f"parsed={result.raw_lines_parsed}; failed={result.raw_lines_failed}"
1041
1049
  )
1042
1050
 
1043
1051
  def _record_stall_safely(self, result: ParseResult) -> None:
@@ -0,0 +1,329 @@
1
+ """Warm-before-ready readiness gate for the in-process local embedder.
2
+
3
+ Phase 2 preparation for the embedding-stability redesign. Everything here is
4
+ **dormant** until a future config flip sets ``REFLEXIO_EMBEDDING_PROVIDER=inprocess``
5
+ with a ``local/*`` default embedding model. Pre-flip (the current daemon-mode
6
+ prod state, or any cloud/off/OSS-dev deployment) none of this changes ``/health``
7
+ or startup behaviour.
8
+
9
+ Three concerns live here:
10
+
11
+ - **D5 warm-before-ready** — a process-level ``threading.Event`` set once the
12
+ in-process embedder has loaded, plus a non-blocking startup thread that loads
13
+ it. ``/health`` reports 503 while the gate is active and the embedder is not
14
+ yet warm, so the load balancer does not route embedding traffic at a worker
15
+ that would pay the ~2-8s cold-load on its first request.
16
+ - **D8 config guards** — loud startup warnings for foot-guns of the in-process
17
+ topology: multiple uvicorn workers (each loads its own model copy), a
18
+ half-configured daemon-disable / provider pair, and an in-process provider set
19
+ alongside a configured service endpoint (which the provider silently overrides).
20
+
21
+ The gate is intentionally cheap to evaluate (no network probe, no provider
22
+ auto-detection) because ``/health`` is the ALB + container health target and is
23
+ polled frequently.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import os
30
+ import threading
31
+ import time
32
+ from collections.abc import Callable
33
+
34
+ _LOGGER = logging.getLogger(__name__)
35
+
36
+ # Warm-before-ready retry: cold model load is ~2-8s and reliable in prod, but a
37
+ # bounded retry absorbs a transient blip before giving up (a give-up leaves
38
+ # /health at 503, forcing a full ECS task replacement).
39
+ _WARM_MAX_ATTEMPTS = 3
40
+ _WARM_RETRY_BACKOFF_S = 2.0
41
+
42
+ _ENV_PROVIDER = "REFLEXIO_EMBEDDING_PROVIDER"
43
+ _ENV_DISABLE_DAEMON = "REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON"
44
+ # Service-endpoint envs, named to match embedding_service_provider.py exactly. An
45
+ # in-process provider takes precedence over both, so either being set alongside
46
+ # PROVIDER=inprocess silently strands the configured service host.
47
+ _ENV_SERVICE_URL = "REFLEXIO_EMBEDDING_SERVICE_URL"
48
+ _ENV_DAEMON_HOST = "REFLEXIO_EMBEDDING_DAEMON_HOST"
49
+ # Recorded by ``reflexio.server.__main__`` before ``uvicorn.run`` so the guard
50
+ # can read the configured worker count from inside a worker process, where
51
+ # uvicorn exposes no worker-count env of its own.
52
+ _ENV_WORKERS = "REFLEXIO_SERVER_WORKERS"
53
+ _ENV_WEB_CONCURRENCY = "WEB_CONCURRENCY"
54
+
55
+ _INPROCESS = "inprocess"
56
+
57
+ # Process-level readiness signal: set once the in-process embedder is loaded.
58
+ _ready = threading.Event()
59
+
60
+
61
+ def mark_embedder_ready() -> None:
62
+ """Mark the in-process embedder as loaded/warm for this process."""
63
+ _ready.set()
64
+
65
+
66
+ def is_embedder_ready() -> bool:
67
+ """Return True once the in-process embedder has been warmed in this process."""
68
+ return _ready.is_set()
69
+
70
+
71
+ def reset_warmup_state_for_test() -> None:
72
+ """Clear the readiness signal. Test-only hygiene helper."""
73
+ _ready.clear()
74
+
75
+
76
+ def _provider() -> str:
77
+ return os.environ.get(_ENV_PROVIDER, "").strip().lower()
78
+
79
+
80
+ def inprocess_local_gate_active() -> bool:
81
+ """Return True iff this deployment serves embeddings via the in-process local path.
82
+
83
+ The gate is active only when BOTH hold:
84
+
85
+ - ``REFLEXIO_EMBEDDING_PROVIDER == "inprocess"`` (explicit config flip), and
86
+ - the resolved default embedding model is a ``local/*`` model.
87
+
88
+ Any other configuration (cloud, off, daemon-mode ``local_service`` /
89
+ ``internal_service``, or no explicit provider at all) returns False, keeping
90
+ the warm-before-ready behaviour dormant. Cheap by construction: neither
91
+ branch performs a network probe or provider auto-detection.
92
+
93
+ Returns:
94
+ bool: True when warm-before-ready ``/health`` gating should apply.
95
+ """
96
+ from reflexio.server.llm.providers.embedding_service_provider import (
97
+ embedding_provider_mode,
98
+ )
99
+
100
+ try:
101
+ mode = embedding_provider_mode()
102
+ except Exception: # noqa: BLE001
103
+ # An invalid REFLEXIO_EMBEDDING_PROVIDER must not turn /health (polled on
104
+ # every ALB probe) into a 500 — treat unresolvable config as gate-off.
105
+ _LOGGER.debug(
106
+ "Embedding provider mode unresolvable; gate inactive", exc_info=True
107
+ )
108
+ return False
109
+ if mode != _INPROCESS:
110
+ return False
111
+
112
+ from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
113
+
114
+ try:
115
+ model = resolve_model_name(ModelRole.EMBEDDING)
116
+ except Exception: # noqa: BLE001
117
+ # No embedding-capable provider resolvable — cannot be the in-process
118
+ # local path, so the gate stays inactive rather than wedging /health.
119
+ _LOGGER.debug("Embedding model resolution failed; gate inactive", exc_info=True)
120
+ return False
121
+ return model.startswith("local/")
122
+
123
+
124
+ def _resolve_local_embedder_loader() -> Callable[[], object] | None:
125
+ """Return a zero-arg loader for the embedder matching the resolved local model.
126
+
127
+ Dispatches on the SAME model the gate resolved so readiness reflects the
128
+ embedder that will actually serve: ``local/nomic-*`` -> ``NomicEmbedder``
129
+ (sentence-transformers), any other ``local/*`` -> ``LocalEmbedder`` (chromadb
130
+ ONNX, e.g. the OSS ``local/minilm-l6-v2`` default). Returns None when no
131
+ in-process local model resolves.
132
+
133
+ Returns:
134
+ Callable[[], object] | None: A loader that warms the correct singleton,
135
+ or None if the resolved model is not an in-process local one.
136
+ """
137
+ from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
138
+
139
+ try:
140
+ model = resolve_model_name(ModelRole.EMBEDDING)
141
+ except Exception: # noqa: BLE001
142
+ return None
143
+ if not model.startswith("local/"):
144
+ return None
145
+ if "nomic" in model:
146
+ from reflexio.server.llm.providers.nomic_embedding_provider import NomicEmbedder
147
+
148
+ return lambda: NomicEmbedder.get()._load()
149
+ from reflexio.server.llm.providers.local_embedding_provider import LocalEmbedder
150
+
151
+ return lambda: LocalEmbedder.get()._load()
152
+
153
+
154
+ def _warm_embedder() -> None:
155
+ """Load the resolved in-process local embedder, then flip the readiness signal.
156
+
157
+ Warms whichever embedder the gate's resolved ``local/*`` model maps to (see
158
+ :func:`_resolve_local_embedder_loader`), so readiness reflects the model that
159
+ will actually serve — not a hardcoded one. Fire-and-forget in a daemon thread
160
+ so a slow load never blocks startup; a bounded retry absorbs a transient blip.
161
+ On final failure the readiness signal stays clear and ``/health`` keeps
162
+ reporting not-ready — the intended fail-safe (deploy fails loud, old rev serves).
163
+ """
164
+ loader = _resolve_local_embedder_loader()
165
+ if loader is None:
166
+ _LOGGER.warning(
167
+ "Warmup gate active but no in-process local embedder resolved; "
168
+ "/health stays not-ready."
169
+ )
170
+ return
171
+ for attempt in range(1, _WARM_MAX_ATTEMPTS + 1):
172
+ try:
173
+ loader()
174
+ mark_embedder_ready()
175
+ _LOGGER.info("In-process embedder warm; /health now reports ready.")
176
+ return
177
+ except Exception: # noqa: BLE001
178
+ _LOGGER.warning(
179
+ "In-process embedder warmup attempt %d/%d failed.",
180
+ attempt,
181
+ _WARM_MAX_ATTEMPTS,
182
+ exc_info=True,
183
+ )
184
+ if attempt < _WARM_MAX_ATTEMPTS:
185
+ time.sleep(_WARM_RETRY_BACKOFF_S * attempt)
186
+ _LOGGER.error(
187
+ "In-process embedder warmup exhausted %d attempts; /health stays not-ready.",
188
+ _WARM_MAX_ATTEMPTS,
189
+ )
190
+
191
+
192
+ def maybe_start_embedder_warmup() -> bool:
193
+ """Spawn the non-blocking warm-before-ready thread when the gate is active.
194
+
195
+ Returns:
196
+ bool: True when a warmup thread was started (gate active), else False.
197
+ """
198
+ if not inprocess_local_gate_active():
199
+ return False
200
+ threading.Thread(target=_warm_embedder, daemon=True, name="embedder-warmup").start()
201
+ return True
202
+
203
+
204
+ def _detected_worker_count() -> int | None:
205
+ """Best-effort read of the configured uvicorn worker count.
206
+
207
+ uvicorn exposes no worker-count env inside a worker process, so
208
+ ``reflexio.server.__main__`` records it in ``REFLEXIO_SERVER_WORKERS``. A
209
+ ``WEB_CONCURRENCY`` fallback covers gunicorn-style entrypoints. Returns
210
+ ``None`` when neither is set (custom entrypoint) — the guard then warns that
211
+ it could not verify the count rather than refusing.
212
+
213
+ Returns:
214
+ int | None: The detected worker count, or None if undetectable.
215
+ """
216
+ for name in (_ENV_WORKERS, _ENV_WEB_CONCURRENCY):
217
+ raw = os.environ.get(name)
218
+ if raw:
219
+ try:
220
+ return int(raw)
221
+ except ValueError:
222
+ continue
223
+ return None
224
+
225
+
226
+ def _guard_workers_multiply_model() -> None:
227
+ """D8(a): warn when in-process mode runs under multiple workers.
228
+
229
+ In-process embedding loads one model copy per worker process, so a
230
+ memory-bounded host that is fine with 1 worker can OOM at N. Warn-only: the
231
+ worker count is not always reliably detectable from inside a worker.
232
+ """
233
+ if _provider() != _INPROCESS:
234
+ return
235
+ count = _detected_worker_count()
236
+ if count is not None and count > 1:
237
+ _LOGGER.warning(
238
+ "%s=%s with %d uvicorn workers: the in-process embedder loads one "
239
+ "model copy PER worker process, multiplying memory on a "
240
+ "memory-bounded task. Prefer 1 worker for in-process embedding, or "
241
+ "run the shared embedding daemon.",
242
+ _ENV_PROVIDER,
243
+ _INPROCESS,
244
+ count,
245
+ )
246
+ elif count is None:
247
+ _LOGGER.warning(
248
+ "%s=%s but the uvicorn worker count could not be verified (neither "
249
+ "%s nor %s is set). If this deployment runs multiple workers, each "
250
+ "loads its own in-process model copy.",
251
+ _ENV_PROVIDER,
252
+ _INPROCESS,
253
+ _ENV_WORKERS,
254
+ _ENV_WEB_CONCURRENCY,
255
+ )
256
+
257
+
258
+ def _guard_daemon_disable_half_pair() -> None:
259
+ """D8(b): warn when the daemon-disable flag and provider disagree.
260
+
261
+ ``REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON`` (truthy) and
262
+ ``REFLEXIO_EMBEDDING_PROVIDER=inprocess`` describe the same intent from two
263
+ angles and are meant to be flipped together. If exactly one is set the
264
+ topology is half-configured (e.g. the daemon is disabled but requests still
265
+ route to daemon mode, or vice-versa).
266
+ """
267
+ from reflexio.server.env_utils import env_truthy
268
+
269
+ daemon_disabled = env_truthy(os.environ.get(_ENV_DISABLE_DAEMON, ""))
270
+ inprocess = _provider() == _INPROCESS
271
+ if daemon_disabled != inprocess:
272
+ _LOGGER.warning(
273
+ "Half-configured in-process embedding: %s=%s and %s=%s disagree. "
274
+ "Set them together (disable the daemon AND select the in-process "
275
+ "provider) or neither.",
276
+ _ENV_DISABLE_DAEMON,
277
+ daemon_disabled,
278
+ _ENV_PROVIDER,
279
+ _provider() or "<unset>",
280
+ )
281
+
282
+
283
+ def _guard_inprocess_overrides_service_host() -> None:
284
+ """D8(c): warn when in-process mode is set alongside a configured service host.
285
+
286
+ ``REFLEXIO_EMBEDDING_PROVIDER=inprocess`` wins the provider-precedence race in
287
+ ``embedding_provider_mode`` outright, so a co-configured
288
+ ``REFLEXIO_EMBEDDING_SERVICE_URL`` / ``REFLEXIO_EMBEDDING_DAEMON_HOST`` is
289
+ silently ignored and this process loads its own in-process local model instead
290
+ of routing to the service. On a GPU-service / self-host fleet that means an
291
+ unintended per-instance CPU model load. Warn-only: the intended in-process
292
+ flip leaves both endpoint envs unset, so this stays silent there.
293
+ """
294
+ if _provider() != _INPROCESS:
295
+ return
296
+ configured = [
297
+ name
298
+ for name in (_ENV_SERVICE_URL, _ENV_DAEMON_HOST)
299
+ if os.environ.get(name, "").strip()
300
+ ]
301
+ if not configured:
302
+ return
303
+ _LOGGER.warning(
304
+ "%s=%s takes precedence and loads an in-process local model, silently "
305
+ "ignoring the configured service endpoint(s): %s. Unset the endpoint(s) "
306
+ "to keep the in-process embedder, or unset %s to route embeddings to the "
307
+ "service.",
308
+ _ENV_PROVIDER,
309
+ _INPROCESS,
310
+ ", ".join(configured),
311
+ _ENV_PROVIDER,
312
+ )
313
+
314
+
315
+ def run_startup_config_guards() -> None:
316
+ """Run the D8 config guards once at server startup (idempotent, warn-only)."""
317
+ _guard_workers_multiply_model()
318
+ _guard_daemon_disable_half_pair()
319
+ _guard_inprocess_overrides_service_host()
320
+
321
+
322
+ __all__ = [
323
+ "inprocess_local_gate_active",
324
+ "is_embedder_ready",
325
+ "mark_embedder_ready",
326
+ "maybe_start_embedder_warmup",
327
+ "reset_warmup_state_for_test",
328
+ "run_startup_config_guards",
329
+ ]
@@ -54,9 +54,19 @@ _LOCAL_SERVICE_PROBE_FAILURE_CACHE_SECONDS = 5.0
54
54
  # never hands out a connection the load balancer already closed.
55
55
  _HTTP_KEEPALIVE_EXPIRY_SECONDS = 50.0
56
56
  _EMBEDDING_RETRY_BACKOFF_SECONDS = 0.1
57
+ # ``embedding_provider_mode`` is called on every embedding request (often several
58
+ # times per request), and a down daemon resolves to ``inprocess`` every time, so
59
+ # an unconditional warning would flood the logs. Emit the fallback WARNING at most
60
+ # once per this interval per process.
61
+ _INPROCESS_FALLBACK_WARN_INTERVAL_SECONDS = 60.0
57
62
  _SERVICE_MODES = {"local_service", "internal_service"}
58
63
  _VALID_MODES = {"cloud", *_SERVICE_MODES, "inprocess", "off"}
59
64
  _local_service_probe_cache: tuple[float, bool, str | None] | None = None
65
+ # Reason the most recent *fresh* /health probe failed (populated by
66
+ # ``_local_service_status``), surfaced in the inprocess-fallback WARNING.
67
+ _last_probe_failure_reason: str | None = None
68
+ # Monotonic timestamp of the last inprocess-fallback WARNING, for rate limiting.
69
+ _last_inprocess_fallback_warn_at: float | None = None
60
70
  _http_client_lock = threading.Lock()
61
71
  _http_client_instance: httpx.Client | None = None
62
72
  _http_client_pid: int | None = None
@@ -154,7 +164,7 @@ def _http_client() -> httpx.Client:
154
164
 
155
165
 
156
166
  def _local_service_status() -> tuple[bool, str | None]:
157
- global _local_service_probe_cache
167
+ global _local_service_probe_cache, _last_probe_failure_reason
158
168
  now = time.monotonic()
159
169
  if _local_service_probe_cache is not None:
160
170
  cached_at, cached_reachable, cached_model = _local_service_probe_cache
@@ -166,6 +176,7 @@ def _local_service_status() -> tuple[bool, str | None]:
166
176
  if now - cached_at < ttl:
167
177
  return cached_reachable, cached_model
168
178
 
179
+ reason: str | None = None
169
180
  try:
170
181
  response = _http_client().get(
171
182
  f"{_local_service_url()}/health",
@@ -175,19 +186,53 @@ def _local_service_status() -> tuple[bool, str | None]:
175
186
  active_model = response.json().get("active_model") if reachable else None
176
187
  if not isinstance(active_model, str):
177
188
  active_model = None
178
- except httpx.HTTPError:
189
+ if not reachable:
190
+ reason = f"/health returned HTTP {response.status_code}"
191
+ except httpx.HTTPError as exc:
179
192
  reachable = False
180
193
  active_model = None
181
- except ValueError:
194
+ reason = f"{type(exc).__name__}: {exc}"
195
+ except ValueError as exc:
182
196
  reachable = False
183
197
  active_model = None
198
+ reason = f"invalid /health response: {exc}"
199
+ _last_probe_failure_reason = reason
184
200
  _local_service_probe_cache = (now, reachable, active_model)
185
201
  return reachable, active_model
186
202
 
187
203
 
188
- def _local_service_supports_model(model: str | None) -> bool:
189
- reachable, active_model = _local_service_status()
190
- return reachable and (active_model is None or active_model == model)
204
+ def _warn_inprocess_fallback(model: str | None, reason: str | None) -> None:
205
+ """Warn (rate-limited) that a ``local/*`` model fell back to the in-process embedder.
206
+
207
+ Emitted only when daemon-mode resolution for a ``local/*`` model fails the
208
+ ``/health`` probe (or the daemon serves a different model) and routing falls
209
+ back to ``inprocess`` — NOT when ``inprocess`` was configured explicitly. The
210
+ fallback loads a second copy of the embedding model into this worker process,
211
+ so operators need to distinguish it from an intentional in-process config.
212
+
213
+ Args:
214
+ model (str | None): The ``local/*`` embedding model being resolved.
215
+ reason (str | None): Why the daemon path was rejected, if known.
216
+ """
217
+ global _last_inprocess_fallback_warn_at
218
+ now = time.monotonic()
219
+ if (
220
+ _last_inprocess_fallback_warn_at is not None
221
+ and now - _last_inprocess_fallback_warn_at
222
+ < _INPROCESS_FALLBACK_WARN_INTERVAL_SECONDS
223
+ ):
224
+ return
225
+ _last_inprocess_fallback_warn_at = now
226
+ _LOGGER.warning(
227
+ "Embedding daemon probe at %s failed for model %r; falling back to an "
228
+ "in-process embedding model copy (a second model is loaded into this "
229
+ "worker process). Probe failure: %s. Further fallback warnings are "
230
+ "suppressed for %.0fs.",
231
+ _local_service_url(),
232
+ model,
233
+ reason or "unknown",
234
+ _INPROCESS_FALLBACK_WARN_INTERVAL_SECONDS,
235
+ )
191
236
 
192
237
 
193
238
  def _ordered_embeddings_from_response(
@@ -260,7 +305,18 @@ def embedding_provider_mode(model: str | None = None) -> EmbeddingProviderMode:
260
305
  return "local_service"
261
306
 
262
307
  if _is_local_model(model):
263
- return "local_service" if _local_service_supports_model(model) else "inprocess"
308
+ reachable, active_model = _local_service_status()
309
+ if reachable and (active_model is None or active_model == model):
310
+ return "local_service"
311
+ if reachable:
312
+ reason = (
313
+ f"daemon is reachable but serves active_model={active_model!r}, "
314
+ f"not {model!r}"
315
+ )
316
+ else:
317
+ reason = _last_probe_failure_reason
318
+ _warn_inprocess_fallback(model, reason)
319
+ return "inprocess"
264
320
  return "cloud"
265
321
 
266
322
 
@@ -311,7 +367,12 @@ def get_service_embeddings(
311
367
  chunk = texts[start : start + chunk_size]
312
368
  embeddings.extend(
313
369
  _post_embedding_batch(
314
- url, model=model, texts=chunk, dimensions=dimensions, timeout=timeout
370
+ url,
371
+ model=model,
372
+ texts=chunk,
373
+ dimensions=dimensions,
374
+ timeout=timeout,
375
+ mode=mode,
315
376
  )
316
377
  )
317
378
  return embeddings
@@ -324,6 +385,7 @@ def _post_embedding_batch(
324
385
  texts: list[str],
325
386
  dimensions: int | None,
326
387
  timeout: float,
388
+ mode: EmbeddingProviderMode,
327
389
  ) -> list[list[float]]:
328
390
  """POST one bounded batch of texts to the embedding service."""
329
391
  payload: dict[str, Any] = {"model": model, "input": texts}
@@ -368,9 +430,22 @@ def _post_embedding_batch(
368
430
  last_error = exc
369
431
  break
370
432
 
371
- _LOGGER.warning("Embedding service unavailable at %s: %s", url, last_error)
433
+ # For HTTP-status failures the response body carries the actionable
434
+ # detail (e.g. the daemon's 409 "already owns model X" message) — without
435
+ # it the log reads as a connectivity problem when it is a config conflict.
436
+ error_detail = str(last_error)
437
+ if isinstance(last_error, httpx.HTTPStatusError):
438
+ body = last_error.response.text.strip()
439
+ if body:
440
+ error_detail = f"{last_error} — response body: {body[:300]}"
441
+ _LOGGER.warning(
442
+ "Embedding service unavailable at %s: %s",
443
+ url,
444
+ error_detail,
445
+ extra={"mode": mode},
446
+ )
372
447
  raise EmbeddingUnavailableError(
373
- f"Embedding service unavailable at {url}: {last_error}"
448
+ f"Embedding service unavailable at {url}: {error_detail}"
374
449
  ) from last_error
375
450
 
376
451
 
@@ -74,7 +74,17 @@ class LocalEmbedder:
74
74
 
75
75
  def __init__(self) -> None:
76
76
  self._ef: Any | None = None
77
+ # Guards lazy construction / cache-recovery swaps of ``self._ef``.
77
78
  self._ef_lock = threading.Lock()
79
+ # Serializes the actual encode call. The shared ONNX embedding function
80
+ # is NOT thread-safe — concurrent ``ef(...)`` calls interleave and
81
+ # corrupt each other's padding/attention buffers (observed in prod as
82
+ # tensor-shape mismatches). ``threading.Lock`` is non-reentrant, so this
83
+ # is a SEPARATE lock from ``_ef_lock`` (the recovery path re-acquires
84
+ # ``_ef_lock`` while we hold this one): fixed order is ``_encode_lock``
85
+ # outer, ``_ef_lock`` inner, which cannot deadlock. NOTE: this lock is
86
+ # not FIFO-fair — do not turn it into a fairness queue without measuring.
87
+ self._encode_lock = threading.Lock()
78
88
 
79
89
  @classmethod
80
90
  def get(cls) -> LocalEmbedder:
@@ -121,12 +131,17 @@ class LocalEmbedder:
121
131
  ``_TARGET_DIM`` (512) floats with the last 128 positions
122
132
  zero-padded.
123
133
  """
124
- ef = self._load()
125
134
  safe_inputs = [(text or "")[:_MAX_CHARS] for text in texts]
126
- try:
127
- raw = ef(safe_inputs)
128
- except Exception as exc: # noqa: BLE001 - Chroma raises varied cache errors.
129
- raw = self._retry_embed_after_cache_clear(ef, exc, safe_inputs)
135
+ # Serialize the encode: the shared ONNX embedder is not thread-safe.
136
+ # ``_load()`` and ``_retry_embed_after_cache_clear()`` both acquire
137
+ # ``_ef_lock`` internally; running them under ``_encode_lock`` fixes the
138
+ # lock order (``_encode_lock`` outer) so recovery can never deadlock.
139
+ with self._encode_lock:
140
+ ef = self._load()
141
+ try:
142
+ raw = ef(safe_inputs)
143
+ except Exception as exc: # noqa: BLE001 - Chroma raises varied cache errors.
144
+ raw = self._retry_embed_after_cache_clear(ef, exc, safe_inputs)
130
145
  return [_pad(vec) for vec in raw]
131
146
 
132
147
  def _retry_embed_after_cache_clear(
@@ -63,12 +63,64 @@ _MAX_CHARS = 32_000
63
63
  _DEFAULT_ENCODE_BATCH_SIZE = 4
64
64
  _ENV_BATCH_SIZE = "REFLEXIO_EMBED_BATCH_SIZE"
65
65
 
66
+ # D7 (Phase 2 prep): optionally pin torch intra-op thread count so the embedder
67
+ # does not oversubscribe CPU under bounded concurrency. Unset (the default) =
68
+ # leave torch untouched, i.e. zero behaviour change. Applies at Nomic model load
69
+ # (both the in-process fallback and the shared daemon run NomicEmbedder). Note the
70
+ # chromadb/minilm LocalEmbedder path uses onnxruntime, not torch, so this knob does
71
+ # not affect it.
72
+ _ENV_TORCH_THREADS = "REFLEXIO_EMBED_TORCH_THREADS"
73
+ _torch_threads_pinned = False
74
+ _torch_threads_lock = threading.Lock()
75
+
66
76
 
67
77
  def _encode_batch_size() -> int:
68
78
  """Resolve the encode mini-batch size from env, defaulting to 4."""
69
79
  return positive_int_env(_ENV_BATCH_SIZE, _DEFAULT_ENCODE_BATCH_SIZE, _LOGGER)
70
80
 
71
81
 
82
+ def _maybe_pin_torch_threads() -> None:
83
+ """Pin torch intra-op threads to ``REFLEXIO_EMBED_TORCH_THREADS`` if set.
84
+
85
+ Dormant by default: when the env var is unset (or blank) this is a no-op and
86
+ ``torch.set_num_threads`` is never called, so torch keeps its default
87
+ autodetected thread count. When set to a positive int it is applied exactly
88
+ once per process (guarded against re-calling). A non-positive or non-integer
89
+ value is ignored with a warning.
90
+ """
91
+ global _torch_threads_pinned
92
+ if _torch_threads_pinned:
93
+ return
94
+ raw = os.environ.get(_ENV_TORCH_THREADS)
95
+ if not raw:
96
+ return
97
+ try:
98
+ n = int(raw)
99
+ except ValueError:
100
+ _LOGGER.warning(
101
+ "%s must be a positive integer; got %r — leaving torch threads at "
102
+ "their default.",
103
+ _ENV_TORCH_THREADS,
104
+ raw,
105
+ )
106
+ return
107
+ if n < 1:
108
+ _LOGGER.warning(
109
+ "%s must be >= 1; got %d — leaving torch threads at their default.",
110
+ _ENV_TORCH_THREADS,
111
+ n,
112
+ )
113
+ return
114
+ with _torch_threads_lock:
115
+ if _torch_threads_pinned:
116
+ return
117
+ import torch
118
+
119
+ torch.set_num_threads(n)
120
+ _torch_threads_pinned = True
121
+ _LOGGER.info("Pinned torch intra-op threads to %d (%s)", n, _ENV_TORCH_THREADS)
122
+
123
+
72
124
  class NomicEmbedderError(RuntimeError):
73
125
  """Raised when the Nomic embedder is requested but its deps are missing."""
74
126
 
@@ -118,6 +170,7 @@ class NomicEmbedder:
118
170
  "sentence-transformers is required for the Nomic local "
119
171
  "embedder. Install with `uv add sentence-transformers`."
120
172
  ) from exc
173
+ _maybe_pin_torch_threads()
121
174
  _LOGGER.info(
122
175
  "Loading Nomic embedding model %s — first call may download "
123
176
  "~550 MB to %s",
@@ -157,15 +210,30 @@ class NomicEmbedder:
157
210
  """
158
211
  model = self._load()
159
212
  safe = [(t or "")[:_MAX_CHARS] for t in texts]
160
- # show_progress_bar=False so server logs stay clean during ingest
161
- # batches. convert_to_numpy=True returns a numpy ndarray; we slice
162
- # and renormalise per-row before converting to plain Python lists.
163
- raw = model.encode(
164
- safe,
165
- batch_size=_encode_batch_size(),
166
- show_progress_bar=False,
167
- convert_to_numpy=True,
168
- )
213
+ # The sentence-transformers model is NOT thread-safe: nomic-bert's
214
+ # rotary-embedding code mutates instance attrs (``_cos_cached`` /
215
+ # ``_sin_cached``) on every forward pass, so two concurrent
216
+ # ``encode()`` calls on this shared singleton corrupt each other's
217
+ # buffers (observed in prod as "size of tensor a (N) must match tensor
218
+ # b (M)" and "'NoneType' object is not subscriptable"). Serialize the
219
+ # encode so every caller — daemon, in-process fallback, prewarm,
220
+ # regeneration — is safe by construction. ``_load()`` acquires and
221
+ # releases ``_model_lock`` and returns before we re-acquire it here, so
222
+ # there is no nesting/deadlock. NOTE: ``threading.Lock`` is not FIFO-fair
223
+ # — do not "improve" this into a fairness queue without measuring; the
224
+ # micro-batch coalescing in ``embedding_service`` is where throughput
225
+ # comes from, not parallel encodes on one model.
226
+ with self._model_lock:
227
+ # show_progress_bar=False so server logs stay clean during ingest
228
+ # batches. convert_to_numpy=True returns a numpy ndarray; we slice
229
+ # and renormalise per-row (below, outside the lock) before
230
+ # converting to plain Python lists.
231
+ raw = model.encode(
232
+ safe,
233
+ batch_size=_encode_batch_size(),
234
+ show_progress_bar=False,
235
+ convert_to_numpy=True,
236
+ )
169
237
  return [_truncate_and_renormalise(vec.tolist()) for vec in raw]
170
238
 
171
239