ltcai 8.9.0 → 9.1.0

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 (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -1,6 +1,6 @@
1
1
  """Machine-checkable architecture readiness gates for release work.
2
2
 
3
- 8.9.0 keeps the major architecture priorities under an explicit release
3
+ The current release keeps the major architecture priorities under an explicit release
4
4
  contract while product maturity work reduces visible beta seams. AgentRuntime, ToolRegistry,
5
5
  central Config, decomposed server runtime, and Knowledge Graph stabilization
6
6
  must remain discoverable, ordered, and backed by tests before the release can be
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "8.9.0"
20
+ ARCHITECTURE_VERSION_TARGET = "9.1.0"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -54,16 +54,64 @@ def _symbol_exists(dotted: str) -> bool:
54
54
  return False
55
55
 
56
56
 
57
+ def _forbidden_patterns(root: Path, relative_path: str, patterns: List[str]) -> List[str]:
58
+ path = root / relative_path
59
+ try:
60
+ source = path.read_text(encoding="utf-8")
61
+ except OSError:
62
+ return [f"missing:{relative_path}"]
63
+ return [pattern for pattern in patterns if pattern in source]
64
+
65
+
57
66
  def architecture_readiness(root: Path | None = None) -> Dict[str, Any]:
58
67
  if root is None:
59
68
  root = Path(__file__).resolve().parents[2]
60
69
  shim_report = legacy_shim_report(root)
70
+ factory_forbidden = _forbidden_patterns(
71
+ root,
72
+ "latticeai/app_factory.py",
73
+ ["build_runtime_namespace(locals", "dict(locals())"],
74
+ )
75
+ model_runtime_forbidden = _forbidden_patterns(
76
+ root,
77
+ "latticeai/services/model_runtime.py",
78
+ [
79
+ "def _sync_globals",
80
+ "global router",
81
+ "STATE = ModelRuntimeState",
82
+ "_STATE_EXPORTS",
83
+ "def __getattr__(name:",
84
+ "from fastapi import HTTPException",
85
+ ],
86
+ )
87
+ model_runtime_forbidden.extend(
88
+ _forbidden_patterns(
89
+ root,
90
+ "latticeai/services/model_loading.py",
91
+ ["from .model_runtime import STATE", "from fastapi import HTTPException"],
92
+ )
93
+ )
94
+ model_runtime_forbidden.extend(
95
+ _forbidden_patterns(
96
+ root,
97
+ "latticeai/services/model_engines.py",
98
+ ["from fastapi import HTTPException", "raise HTTPException"],
99
+ )
100
+ )
101
+ agent_alias_forbidden = _forbidden_patterns(
102
+ root,
103
+ "latticeai/core/agent.py",
104
+ ["AgentRuntime = SingleAgentRuntime"],
105
+ )
61
106
 
62
107
  gates = [
63
108
  ArchitectureGate(
64
109
  id="agent-runtime",
65
110
  title="AgentRuntime boundary",
66
- status="complete" if _symbol_exists("lattice_brain.runtime.agent_runtime.AgentRuntime") else "incomplete",
111
+ status="complete" if (
112
+ _symbol_exists("lattice_brain.runtime.agent_runtime.AgentRuntime")
113
+ and not agent_alias_forbidden
114
+ ) else "incomplete",
67
115
  evidence=[
68
116
  "lattice_brain.runtime.agent_runtime.AgentRuntime",
69
117
  "latticeai.api.agents.create_agents_router(agent_runtime=...)",
@@ -83,7 +131,11 @@ def architecture_readiness(root: Path | None = None) -> Dict[str, Any]:
83
131
  ArchitectureGate(
84
132
  id="config-centralization",
85
133
  title="Central app Config",
86
- status="complete" if _symbol_exists("latticeai.core.config.Config") else "incomplete",
134
+ status="complete" if (
135
+ _symbol_exists("latticeai.core.config.Config")
136
+ and _symbol_exists("latticeai.runtime.config_runtime.ConfigRuntime")
137
+ and not model_runtime_forbidden
138
+ ) else "incomplete",
87
139
  evidence=[
88
140
  "latticeai.core.config.Config.from_env",
89
141
  "latticeai.runtime.config_runtime.ConfigRuntime",
@@ -93,7 +145,19 @@ def architecture_readiness(root: Path | None = None) -> Dict[str, Any]:
93
145
  ArchitectureGate(
94
146
  id="server-decomposition",
95
147
  title="Server decomposition",
96
- status="complete",
148
+ status="complete" if (
149
+ all(
150
+ _symbol_exists(symbol)
151
+ for symbol in [
152
+ "latticeai.runtime.config_runtime.ConfigRuntime",
153
+ "latticeai.runtime.security_runtime.SecurityRuntime",
154
+ "latticeai.runtime.brain_runtime.BrainRuntime",
155
+ "latticeai.runtime.model_wiring.ModelRuntime",
156
+ "latticeai.runtime.router_registration.RouterBundle",
157
+ ]
158
+ )
159
+ and not factory_forbidden
160
+ ) else "incomplete",
97
161
  evidence=[
98
162
  "latticeai.app_factory.create_app composition root",
99
163
  "latticeai.api.* domain routers",
@@ -192,6 +256,11 @@ def architecture_readiness(root: Path | None = None) -> Dict[str, Any]:
192
256
  "runtime_modules": runtime_module_count,
193
257
  "architecture_gates": len(gates),
194
258
  "legacy_shims_remaining": shim_report["remaining_count"],
259
+ "forbidden_patterns": {
260
+ "app_factory": factory_forbidden,
261
+ "model_runtime": model_runtime_forbidden,
262
+ "agent_alias": agent_alias_forbidden,
263
+ },
195
264
  },
196
265
  "legacy_compatibility": shim_report,
197
266
  }
@@ -136,6 +136,7 @@ def build_brain_automation_workflow(recipe_id: str, *, enabled: bool = False) ->
136
136
  trigger_config = {
137
137
  **deepcopy(recipe.trigger),
138
138
  "enabled": bool(enabled),
139
+ "review_queue": True,
139
140
  "consent_required": True,
140
141
  "local_only": True,
141
142
  "external_actions": False,
@@ -156,7 +157,9 @@ def build_brain_automation_workflow(recipe_id: str, *, enabled: bool = False) ->
156
157
  "name": "Draft Brain review",
157
158
  "config": {
158
159
  "agent": "agent:planner",
160
+ "goal": recipe.prompt,
159
161
  "prompt": recipe.prompt,
162
+ "roles": ["researcher", "planner", "executor", "reviewer"],
160
163
  "mode": "draft",
161
164
  "local_only": True,
162
165
  "external_actions": False,
@@ -186,29 +189,3 @@ def build_brain_automation_workflow(recipe_id: str, *, enabled: bool = False) ->
186
189
  "creates": list(recipe.creates),
187
190
  },
188
191
  }
189
-
190
-
191
- # === A방향 (Act/automation + BrainAutomationPanel) E2E 시나리오 초안 ===
192
- # (backend 인터페이스 list_brain_automation_recipes / find_installed... / build_... 완료 후 즉시 작성)
193
- # 1. Recipe 목록 노출: frontend BrainAutomationPanel이 list_brain_automation_recipes() 호출 → recipes + consent metadata 표시.
194
- # 2. "Create reviewable draft" 클릭:
195
- # - build_brain_automation_workflow(recipe_id, enabled=False) 로 draft 생성 (metadata.recipe_id + created_from=brain_automation_recipe)
196
- # - find_installed_recipe_workflow 로 사전 dedup 체크 → 이미 있으면 기존 반환, UI disabled + "✓ Reviewable draft ready" 피드백.
197
- # - 생성된 workflow는 automation_state="draft_disabled", trigger.enabled=False → TriggerService 무시.
198
- # 3. Dedup guard (UI + backend): metadata.recipe_id + created_from 기준. 중복 클릭 가드 (no-dup) 로 double submit 방지.
199
- # 4. User가 draft를 review/수정 후 enabled=True 로 전환 → TriggerService._triggered_workflows 에서 enabled True인 것만 arm.
200
- # 5. Interval trigger E2E:
201
- # - reconcile_missed() : 다운타임 중 missed → "skipped" 이벤트 기록 (catch-up storm 없음).
202
- # - tick_intervals() : last_fired_at + interval + last_attempt_at dedup 가드 (10s cooldown) 로 중복 실행 방지.
203
- # - LATTICE_TZ 환경변수: describe()에 "tz" 노출, 이벤트 at 값은 epoch이지만 클라이언트가 LATTICE_TZ로 현지화.
204
- # 6. Brain event trigger E2E: kg_ingest.* post_tool hook → on_brain_event → matching source_type 필터 → _fire (dedup 5s).
205
- # 7. Failure degraded:
206
- # - _fire 에서 run_workflow 예외 → _record_fire_outcome → consecutive_failures++ , describe() "status":"degraded" ( >=3 ).
207
- # - 성공 시 reset. (실행 내부 실패는 workflow run record에 남음, scheduler는 launch health만).
208
- # 8. Run provenance: fired run의 inputs["__trigger__"] = {"type": "interval"|"brain_event", ...} 로 감사/디버그 가능.
209
- # 9. Consent-first: draft_disabled 기본, user enable 전까지 절대 실행 안 됨. "review_before_run": True.
210
- # 10. End-to-end Act: draft → enable → trigger fire → agent:planner "Draft Brain review" 노드 → output (requires_review) → user review → save or discard.
211
- #
212
- # 다음: 실제 API (e.g. POST /brain/automation/install-draft) 가 UI에서 호출되면 위 시나리오에 대한 통합 테스트 + RunExecutor 경로 검증 즉시 추가.
213
- # 현재 상태: backend recipe 인터페이스 + TriggerService edge 하드닝 + AgentRuntime wiring 완료. UI (App.tsx + styles) + test_brain_automation.py 는 별도 완료 보고됨.
214
-
@@ -1,37 +1,191 @@
1
- """Chat / session service seam.
1
+ """Non-HTTP chat orchestration: history persistence and answer traces.
2
2
 
3
- The streaming chat path in ``server_app`` is intentionally left in place — its
4
- generator and SSE behaviour are sensitive. This service provides a stable seam
5
- for the *bookkeeping* around answers (conversation history access and
6
- Workspace-OS answer-trace recording) so those concerns are named and wrapped
7
- rather than reaching into the store directly from the streaming handler.
3
+ The API layer owns FastAPI requests/responses and SSE framing. This service
4
+ owns the reusable bookkeeping that must be identical across normal answers,
5
+ streamed answers, document generation, and fast-path intents:
8
6
 
9
- It is a behaviour-preserving façade: methods forward to the injected history
10
- accessor and :class:`WorkspaceOSStore`, so wiring the chat path through it
11
- cannot change streaming output.
7
+ * workspace/user-scoped history reads;
8
+ * asynchronous persistence of user/assistant exchanges;
9
+ * Graph-RAG answer trace construction and recording;
10
+ * history search/grouping independent of HTTP.
11
+
12
+ ``coerce`` preserves lightweight test/embedding contexts that provide the old
13
+ ``build_graph_trace``/``record_trace`` façade without requiring them to grow a
14
+ full service implementation.
12
15
  """
13
16
 
14
17
  from __future__ import annotations
15
18
 
19
+ import asyncio
16
20
  from typing import Any, Callable, Dict, List, Optional
17
21
 
18
22
  from latticeai.core.workspace_os import WorkspaceOSStore
19
23
 
20
24
 
21
25
  class ChatService:
22
- def __init__(self, *, store: WorkspaceOSStore, get_history: Callable[[], List[Dict[str, Any]]]):
26
+ def __init__(
27
+ self,
28
+ *,
29
+ store: WorkspaceOSStore,
30
+ get_history: Callable[..., List[Dict[str, Any]]],
31
+ save_to_history: Optional[Callable[..., None]] = None,
32
+ get_history_user: Optional[Callable[..., Dict[str, Any]]] = None,
33
+ trace_delegate: Any = None,
34
+ ):
23
35
  self._store = store
24
36
  self._get_history = get_history
37
+ self._save_to_history = save_to_history
38
+ self._get_history_user = get_history_user
39
+ self._trace_delegate = trace_delegate
40
+
41
+ @classmethod
42
+ def coerce(
43
+ cls,
44
+ candidate: Any,
45
+ *,
46
+ store: Any,
47
+ get_history: Callable[..., List[Dict[str, Any]]],
48
+ save_to_history: Callable[..., None],
49
+ get_history_user: Callable[..., Dict[str, Any]],
50
+ ) -> "ChatService":
51
+ """Return a fully wired service while preserving legacy trace fakes."""
52
+
53
+ if isinstance(candidate, cls):
54
+ candidate._get_history = get_history
55
+ candidate._save_to_history = save_to_history
56
+ candidate._get_history_user = get_history_user
57
+ return candidate
58
+ return cls(
59
+ store=store,
60
+ get_history=get_history,
61
+ save_to_history=save_to_history,
62
+ get_history_user=get_history_user,
63
+ trace_delegate=candidate,
64
+ )
25
65
 
26
66
  # ── conversation history ─────────────────────────────────────────────
27
67
 
28
- def history(self) -> List[Dict[str, Any]]:
29
- return self._get_history()
68
+ def history(self, **scope: Any) -> List[Dict[str, Any]]:
69
+ return self._get_history(**scope)
70
+
71
+ def history_scope(
72
+ self,
73
+ user_email: Optional[str],
74
+ *,
75
+ require_auth: bool,
76
+ allowed_workspaces_for: Optional[Callable[[str], Any]] = None,
77
+ ) -> Dict[str, Any]:
78
+ scoped_user = user_email if require_auth else None
79
+ allowed = None
80
+ if require_auth and scoped_user and allowed_workspaces_for is not None:
81
+ allowed = allowed_workspaces_for(scoped_user)
82
+ return {
83
+ "user_email": scoped_user,
84
+ "allowed_workspaces": allowed,
85
+ "include_legacy_global": not require_auth,
86
+ }
87
+
88
+ def history_user(
89
+ self,
90
+ user_email: Optional[str],
91
+ user_nickname: Optional[str],
92
+ ) -> Dict[str, Any]:
93
+ if self._get_history_user is None:
94
+ return {}
95
+ return self._get_history_user(user_email, user_nickname)
96
+
97
+ async def persist_entry(
98
+ self,
99
+ role: str,
100
+ content: str,
101
+ *,
102
+ history_meta: Optional[Dict[str, Any]] = None,
103
+ history_user: Optional[Dict[str, Any]] = None,
104
+ ) -> None:
105
+ if self._save_to_history is None:
106
+ raise RuntimeError("chat history writer is not configured")
107
+ await asyncio.to_thread(
108
+ self._save_to_history,
109
+ role,
110
+ content,
111
+ **(history_meta or {}),
112
+ **(history_user or {}),
113
+ )
114
+
115
+ async def persist_exchange(
116
+ self,
117
+ *,
118
+ request_message: str,
119
+ stored_user_message: str,
120
+ answer: str,
121
+ source: Optional[str],
122
+ history_meta: Dict[str, Any],
123
+ history_user: Dict[str, Any],
124
+ notify: Optional[Callable[[str, str, Optional[str]], None]] = None,
125
+ ) -> None:
126
+ await self.persist_entry(
127
+ "user",
128
+ stored_user_message,
129
+ history_meta=history_meta,
130
+ history_user=history_user,
131
+ )
132
+ await self.persist_entry(
133
+ "assistant",
134
+ answer,
135
+ history_meta=history_meta,
136
+ history_user=history_user,
137
+ )
138
+ if notify is not None:
139
+ notify("user", request_message, source)
140
+ notify("assistant", answer, source)
141
+
142
+ def search_history(
143
+ self,
144
+ query: str,
145
+ *,
146
+ scope: Dict[str, Any],
147
+ conversation_title: Callable[[Dict[str, Any]], str],
148
+ limit: int = 30,
149
+ ) -> List[Dict[str, Any]]:
150
+ q_lower = str(query or "").strip().lower()
151
+ if not q_lower:
152
+ return []
153
+ matches = [
154
+ item
155
+ for item in self.history(**scope)
156
+ if q_lower in str(item.get("content") or "").lower()
157
+ ]
158
+ grouped: Dict[str, Dict[str, Any]] = {}
159
+ for item in matches:
160
+ conversation_id = item.get("conversation_id") or "legacy"
161
+ if conversation_id not in grouped:
162
+ grouped[conversation_id] = {
163
+ "conversation_id": conversation_id,
164
+ "title": conversation_title(item),
165
+ "messages": [],
166
+ }
167
+ grouped[conversation_id]["messages"].append(item)
168
+ return list(grouped.values())[-max(1, int(limit or 30)) :]
30
169
 
31
170
  # ── answer-trace recording (Graph RAG) ───────────────────────────────
32
171
 
33
- def build_graph_trace(self, question: str, graph: Any, context: str = "", *, limit: int = 8) -> Dict[str, Any]:
34
- return self._store.build_graph_trace(question, graph, context, limit=limit)
172
+ def build_graph_trace(
173
+ self,
174
+ question: str,
175
+ graph: Any,
176
+ context: str = "",
177
+ *,
178
+ limit: int = 8,
179
+ allowed_workspaces=None,
180
+ ) -> Dict[str, Any]:
181
+ target = self._trace_delegate or self._store
182
+ return target.build_graph_trace(
183
+ question,
184
+ graph,
185
+ context,
186
+ limit=limit,
187
+ allowed_workspaces=allowed_workspaces,
188
+ )
35
189
 
36
190
  def record_trace(
37
191
  self,
@@ -43,7 +197,37 @@ class ChatService:
43
197
  trace: Dict[str, Any],
44
198
  workspace_id: Optional[str] = None,
45
199
  ) -> Dict[str, Any]:
46
- return self._store.record_trace(
200
+ target = self._trace_delegate or self._store
201
+ return target.record_trace(
202
+ question=question,
203
+ response=response,
204
+ conversation_id=conversation_id,
205
+ user_email=user_email,
206
+ trace=trace,
207
+ workspace_id=workspace_id,
208
+ )
209
+
210
+ async def persist_answer(
211
+ self,
212
+ *,
213
+ question: str,
214
+ response: str,
215
+ conversation_id: Optional[str],
216
+ user_email: Optional[str],
217
+ user_nickname: Optional[str],
218
+ source: Optional[str],
219
+ trace: Dict[str, Any],
220
+ workspace_id: Optional[str],
221
+ history_meta: Dict[str, Any],
222
+ notify: Optional[Callable[[str, str, Optional[str]], None]] = None,
223
+ ) -> Dict[str, Any]:
224
+ await self.persist_entry(
225
+ "assistant",
226
+ response,
227
+ history_meta=history_meta,
228
+ history_user=self.history_user(user_email, user_nickname),
229
+ )
230
+ trace_record = self.record_trace(
47
231
  question=question,
48
232
  response=response,
49
233
  conversation_id=conversation_id,
@@ -51,3 +235,9 @@ class ChatService:
51
235
  trace=trace,
52
236
  workspace_id=workspace_id,
53
237
  )
238
+ if notify is not None:
239
+ notify("assistant", response, source)
240
+ return trace_record
241
+
242
+
243
+ __all__ = ["ChatService"]