ltcai 3.6.0 → 4.0.1

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 (238) hide show
  1. package/README.md +39 -31
  2. package/docs/CHANGELOG.md +64 -0
  3. package/docs/REALTIME_COLLABORATION.md +3 -3
  4. package/docs/V3_FRONTEND.md +9 -8
  5. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  6. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
  7. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  8. package/docs/kg-schema.md +51 -53
  9. package/docs/spec-vs-impl.md +10 -10
  10. package/kg_schema.py +2 -520
  11. package/knowledge_graph.py +37 -4629
  12. package/knowledge_graph_api.py +11 -127
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +16 -17
  15. package/latticeai/api/agents.py +20 -7
  16. package/latticeai/api/auth.py +46 -15
  17. package/latticeai/api/chat.py +112 -76
  18. package/latticeai/api/health.py +1 -1
  19. package/latticeai/api/hooks.py +1 -1
  20. package/latticeai/api/invitations.py +100 -0
  21. package/latticeai/api/knowledge_graph.py +139 -0
  22. package/latticeai/api/local_files.py +1 -1
  23. package/latticeai/api/mcp.py +23 -11
  24. package/latticeai/api/memory.py +1 -1
  25. package/latticeai/api/models.py +1 -1
  26. package/latticeai/api/network.py +81 -0
  27. package/latticeai/api/plugins.py +3 -6
  28. package/latticeai/api/realtime.py +5 -8
  29. package/latticeai/api/search.py +26 -2
  30. package/latticeai/api/security_dashboard.py +2 -3
  31. package/latticeai/api/setup.py +2 -2
  32. package/latticeai/api/static_routes.py +11 -16
  33. package/latticeai/api/tools.py +3 -0
  34. package/latticeai/api/ui_redirects.py +26 -0
  35. package/latticeai/api/workflow_designer.py +85 -6
  36. package/latticeai/api/workspace.py +93 -57
  37. package/latticeai/app_factory.py +1781 -0
  38. package/latticeai/brain/__init__.py +18 -0
  39. package/latticeai/brain/_kg_common.py +1123 -0
  40. package/latticeai/brain/context.py +213 -0
  41. package/latticeai/brain/conversations.py +236 -0
  42. package/latticeai/brain/discovery.py +1455 -0
  43. package/latticeai/brain/documents.py +218 -0
  44. package/latticeai/brain/identity.py +175 -0
  45. package/latticeai/brain/ingest.py +644 -0
  46. package/latticeai/brain/memory.py +102 -0
  47. package/latticeai/brain/network.py +205 -0
  48. package/latticeai/brain/projection.py +561 -0
  49. package/latticeai/brain/provenance.py +401 -0
  50. package/latticeai/brain/retrieval.py +1316 -0
  51. package/latticeai/brain/schema.py +640 -0
  52. package/latticeai/brain/store.py +216 -0
  53. package/latticeai/brain/write_master.py +225 -0
  54. package/latticeai/core/agent.py +31 -7
  55. package/latticeai/core/audit.py +0 -7
  56. package/latticeai/core/config.py +1 -1
  57. package/latticeai/core/context_builder.py +1 -2
  58. package/latticeai/core/enterprise.py +1 -1
  59. package/latticeai/core/graph_curator.py +2 -2
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/mcp_registry.py +791 -0
  63. package/latticeai/core/model_compat.py +1 -1
  64. package/latticeai/core/model_resolution.py +0 -1
  65. package/latticeai/core/multi_agent.py +238 -4
  66. package/latticeai/core/policy.py +54 -0
  67. package/latticeai/core/realtime.py +65 -44
  68. package/latticeai/core/security.py +1 -1
  69. package/latticeai/core/sessions.py +66 -10
  70. package/latticeai/core/users.py +147 -0
  71. package/latticeai/core/workflow_engine.py +114 -2
  72. package/latticeai/core/workspace_os.py +477 -29
  73. package/latticeai/models/__init__.py +7 -0
  74. package/latticeai/models/router.py +779 -0
  75. package/latticeai/server_app.py +29 -1536
  76. package/latticeai/services/agent_runtime.py +243 -4
  77. package/latticeai/services/app_context.py +75 -14
  78. package/latticeai/services/ingestion.py +47 -0
  79. package/latticeai/services/kg_portability.py +33 -3
  80. package/latticeai/services/memory_service.py +39 -11
  81. package/latticeai/services/model_runtime.py +2 -5
  82. package/latticeai/services/platform_runtime.py +100 -23
  83. package/latticeai/services/run_executor.py +328 -0
  84. package/latticeai/services/search_service.py +17 -8
  85. package/latticeai/services/tool_dispatch.py +12 -2
  86. package/latticeai/services/triggers.py +241 -0
  87. package/latticeai/services/upload_service.py +37 -12
  88. package/latticeai/services/workspace_service.py +55 -16
  89. package/llm_router.py +29 -772
  90. package/ltcai_cli.py +1 -2
  91. package/mcp_registry.py +25 -788
  92. package/p_reinforce.py +124 -14
  93. package/package.json +10 -20
  94. package/scripts/bump_version.py +99 -0
  95. package/scripts/generate_diagrams.py +0 -1
  96. package/scripts/lint_v3.mjs +105 -18
  97. package/scripts/validate_release_artifacts.py +0 -1
  98. package/scripts/wheel_smoke.py +142 -0
  99. package/server.py +11 -7
  100. package/setup_wizard.py +1142 -0
  101. package/static/sw.js +81 -52
  102. package/static/v3/asset-manifest.json +33 -25
  103. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  104. package/static/v3/css/lattice.base.css +1 -1
  105. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  106. package/static/v3/css/lattice.components.css +1 -1
  107. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  108. package/static/v3/css/lattice.shell.css +1 -1
  109. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  110. package/static/v3/css/lattice.tokens.css +3 -0
  111. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  112. package/static/v3/css/lattice.views.css +2 -2
  113. package/static/v3/index.html +3 -4
  114. package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
  115. package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
  116. package/static/v3/js/core/api.js +57 -0
  117. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  118. package/static/v3/js/core/i18n.js +575 -0
  119. package/static/v3/js/core/routes.37522821.js +101 -0
  120. package/static/v3/js/core/routes.js +71 -63
  121. package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
  122. package/static/v3/js/core/shell.js +66 -37
  123. package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
  124. package/static/v3/js/core/store.js +11 -1
  125. package/static/v3/js/views/account.eff40715.js +143 -0
  126. package/static/v3/js/views/account.js +143 -0
  127. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  128. package/static/v3/js/views/activity.js +67 -0
  129. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  130. package/static/v3/js/views/admin-users.js +4 -6
  131. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  132. package/static/v3/js/views/agents.js +35 -12
  133. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  134. package/static/v3/js/views/chat.js +23 -0
  135. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  136. package/static/v3/js/views/graph-canvas.js +509 -0
  137. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  138. package/static/v3/js/views/hybrid-search.js +1 -2
  139. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
  140. package/static/v3/js/views/knowledge-graph.js +60 -44
  141. package/static/v3/js/views/network.52a4f181.js +97 -0
  142. package/static/v3/js/views/network.js +97 -0
  143. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  144. package/static/v3/js/views/planning.js +26 -5
  145. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  146. package/static/v3/js/views/runs.js +144 -0
  147. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  148. package/static/v3/js/views/settings.js +7 -8
  149. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  150. package/static/v3/js/views/snapshots.js +135 -0
  151. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  152. package/static/v3/js/views/workflows.js +87 -2
  153. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  154. package/static/v3/js/views/workspace-admin.js +156 -0
  155. package/static/vendor/chart.umd.min.js +20 -0
  156. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  157. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  158. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  159. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  160. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  161. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  162. package/static/vendor/fonts/inter.css +44 -0
  163. package/static/vendor/icons/tabler-icons.min.css +4 -0
  164. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  165. package/static/vendor/marked.min.js +69 -0
  166. package/telegram_bot.py +1 -2
  167. package/tools/commands.py +4 -2
  168. package/tools/computer.py +1 -1
  169. package/tools/documents.py +1 -3
  170. package/tools/filesystem.py +0 -4
  171. package/tools/knowledge.py +1 -3
  172. package/tools/network.py +1 -3
  173. package/codex_telegram_bot.py +0 -195
  174. package/docs/assets/v3.4.0/agent-run.png +0 -0
  175. package/docs/assets/v3.4.0/agents.png +0 -0
  176. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  177. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  178. package/docs/assets/v3.4.0/chat.png +0 -0
  179. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  180. package/docs/assets/v3.4.0/files.png +0 -0
  181. package/docs/assets/v3.4.0/home.png +0 -0
  182. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  183. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  184. package/docs/assets/v3.4.0/local-agent.png +0 -0
  185. package/docs/assets/v3.4.0/memory.png +0 -0
  186. package/docs/assets/v3.4.0/settings.png +0 -0
  187. package/docs/assets/v3.4.0/vision-input.png +0 -0
  188. package/docs/assets/v3.4.0/workflows.png +0 -0
  189. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  190. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  191. package/docs/assets/v3.4.1/local-agent.png +0 -0
  192. package/docs/images/admin-dashboard.png +0 -0
  193. package/docs/images/architecture.png +0 -0
  194. package/docs/images/enterprise.png +0 -0
  195. package/docs/images/graph.png +0 -0
  196. package/docs/images/hero.gif +0 -0
  197. package/docs/images/knowledge-graph.png +0 -0
  198. package/docs/images/lattice-ai-demo.gif +0 -0
  199. package/docs/images/lattice-ai-hero.png +0 -0
  200. package/docs/images/logo.svg +0 -33
  201. package/docs/images/mobile-responsive.png +0 -0
  202. package/docs/images/model-recommendation.png +0 -0
  203. package/docs/images/onboarding.png +0 -0
  204. package/docs/images/organization.png +0 -0
  205. package/docs/images/pipeline.png +0 -0
  206. package/docs/images/screenshot-admin.png +0 -0
  207. package/docs/images/screenshot-chat.png +0 -0
  208. package/docs/images/screenshot-graph.png +0 -0
  209. package/docs/images/skills.png +0 -0
  210. package/docs/images/workspace-dark.png +0 -0
  211. package/docs/images/workspace-light.png +0 -0
  212. package/docs/images/workspace.png +0 -0
  213. package/requirements.txt +0 -16
  214. package/static/account.html +0 -115
  215. package/static/activity.html +0 -73
  216. package/static/admin.html +0 -488
  217. package/static/agents.html +0 -139
  218. package/static/chat.html +0 -844
  219. package/static/css/reference/account.css +0 -439
  220. package/static/css/reference/admin.css +0 -610
  221. package/static/css/reference/base.css +0 -1661
  222. package/static/css/reference/chat.css +0 -4623
  223. package/static/css/reference/graph.css +0 -1016
  224. package/static/css/responsive.css +0 -861
  225. package/static/graph.html +0 -124
  226. package/static/platform.css +0 -104
  227. package/static/plugins.html +0 -136
  228. package/static/scripts/account.js +0 -238
  229. package/static/scripts/admin.js +0 -1614
  230. package/static/scripts/chat.js +0 -5081
  231. package/static/scripts/graph.js +0 -1804
  232. package/static/scripts/platform.js +0 -64
  233. package/static/scripts/ux.js +0 -167
  234. package/static/scripts/workspace.js +0 -948
  235. package/static/v3/js/core/routes.2ce3815a.js +0 -93
  236. package/static/workflows.html +0 -146
  237. package/static/workspace.css +0 -1121
  238. package/static/workspace.html +0 -357
@@ -25,9 +25,8 @@ from latticeai.core.agent import AgentRunContext, AgentState
25
25
  from latticeai.core.context_builder import format_sources_footnote, retrieve_context_for_generation
26
26
  from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
27
27
  from latticeai.core.hooks import dispatch_tool
28
- from latticeai.services.chat_service import ChatService
28
+ from latticeai.services.app_context import AppContext
29
29
  from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
30
- from telegram_bot import broadcast_web_chat
31
30
  from tools import AGENT_ROOT, ToolError, ensure_agent_root, execute_tool, knowledge_save, local_read, network_status
32
31
 
33
32
  class ChatRequest(BaseModel):
@@ -71,6 +70,28 @@ class AgentEvalRequest(BaseModel):
71
70
  skill: str
72
71
  case_id: Optional[str] = None
73
72
 
73
+ def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
74
+ """Restrict history to one user's exchange.
75
+
76
+ Keeps the user's own messages plus assistant replies that directly follow
77
+ them. A bare role=="assistant" pass would leak every other user's replies
78
+ into this user's prompt context.
79
+ """
80
+ paired: List[Dict] = []
81
+ include_next_assistant = False
82
+ for item in history:
83
+ if item.get("role") == "assistant":
84
+ if include_next_assistant:
85
+ paired.append(item)
86
+ include_next_assistant = False
87
+ elif item.get("user_email") == user_email:
88
+ paired.append(item)
89
+ include_next_assistant = True
90
+ else:
91
+ include_next_assistant = False
92
+ return paired
93
+
94
+
74
95
  def detect_language(text: str) -> str:
75
96
  """Detect language: 'ko' (Korean) or 'en' (English)."""
76
97
  total = max(len(text), 1)
@@ -120,46 +141,57 @@ async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[
120
141
  yield "data: [DONE]\n\n"
121
142
 
122
143
 
123
- def create_chat_router(
124
- *,
125
- config,
126
- model_router,
127
- chat_service: ChatService,
128
- workspace_store,
129
- workspace_graph,
130
- gardener,
131
- require_user,
132
- enforce_rate_limit,
133
- get_history_user,
134
- save_to_history,
135
- append_audit_event,
136
- clear_history,
137
- clear_conversation,
138
- get_history,
139
- group_history_conversations,
140
- get_conversation_messages,
141
- conversation_title,
142
- load_users,
143
- get_user_role,
144
- enable_graph: bool,
145
- knowledge_graph,
146
- public_model: str,
147
- base_dir: Path,
148
- hooks=None,
149
- ) -> APIRouter:
144
+ def create_chat_router(context: AppContext) -> APIRouter:
145
+ """Build the chat/history/agent router from the typed application context.
146
+
147
+ Replaces the historical ~25-kwarg factory signature: ``context``
148
+ (:class:`latticeai.services.app_context.AppContext`) carries the same
149
+ dependencies as typed fields.
150
+ """
150
151
  api_router = APIRouter()
151
- router = model_router
152
- CONFIG = config
153
- CHAT_SERVICE = chat_service
154
- WORKSPACE_OS = workspace_store
155
- ENABLE_GRAPH = enable_graph
156
- KNOWLEDGE_GRAPH = knowledge_graph
157
- PUBLIC_MODEL = public_model
158
- BASE_DIR = base_dir
152
+ router = context.model_router
153
+ hooks = context.hooks
154
+ workspace_graph = context.workspace_graph
155
+ context_assembler = context.context_assembler
156
+ require_user = context.require_user
157
+ enforce_rate_limit = context.enforce_rate_limit
158
+ get_history_user = context.get_history_user
159
+ save_to_history = context.save_to_history
160
+ append_audit_event = context.append_audit_event
161
+ clear_history = context.clear_history
162
+ clear_conversation = context.clear_conversation
163
+ get_history = context.get_history
164
+ group_history_conversations = context.group_history_conversations
165
+ get_conversation_messages = context.get_conversation_messages
166
+ conversation_title = context.conversation_title
167
+
168
+ CONFIG = context.config
169
+ CHAT_SERVICE = context.chat_service
170
+ WORKSPACE_OS = context.workspace_store
171
+ ENABLE_GRAPH = context.enable_graph
172
+ KNOWLEDGE_GRAPH = context.knowledge_graph
173
+ PUBLIC_MODEL = context.public_model
174
+ BASE_DIR = context.base_dir
159
175
  _doc_gen_sessions: dict = {}
160
176
  _pending_agents: dict[str, tuple] = {}
161
177
  _pending_agents_lock = threading.Lock()
162
178
 
179
+ on_chat_message = context.on_chat_message
180
+
181
+ def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
182
+ """Mirror a chat exchange to the injected external bridge, if any.
183
+
184
+ ``on_chat_message`` is registered by ``create_app`` only when
185
+ ENABLE_TELEGRAM is truthy; exchanges that originated *from* telegram
186
+ are never echoed back. No bridge registered → no-op.
187
+ """
188
+ if on_chat_message is None or source == "telegram":
189
+ return
190
+ try:
191
+ on_chat_message(role, text, source)
192
+ except Exception as exc:
193
+ logging.warning("chat message bridge failed: %s", exc)
194
+
163
195
  def build_recent_chat_context(
164
196
  limit: int = 10,
165
197
  include_image_missing_replies: bool = True,
@@ -170,7 +202,7 @@ def create_chat_router(
170
202
  if conversation_id:
171
203
  history = [item for item in history if item.get("conversation_id") == conversation_id]
172
204
  if user_email:
173
- history = [item for item in history if item.get("user_email") == user_email or item.get("role") == "assistant"]
205
+ history = pair_user_history(history, user_email)
174
206
  history = history[-limit:]
175
207
  lines = []
176
208
  for item in history:
@@ -250,6 +282,7 @@ def create_chat_router(
250
282
  knowledge_save=knowledge_save,
251
283
  audit=append_audit_event,
252
284
  hooks=hooks,
285
+ brain_memory=context.brain_memory,
253
286
  )
254
287
 
255
288
  @api_router.post("/chat")
@@ -272,9 +305,8 @@ def create_chat_router(
272
305
  except ToolError as exc:
273
306
  answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
274
307
  save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
275
- if req.source != "telegram":
276
- asyncio.create_task(broadcast_web_chat("user", req.message))
277
- asyncio.create_task(broadcast_web_chat("assistant", answer))
308
+ notify_chat_message("user", req.message, req.source)
309
+ notify_chat_message("assistant", answer, req.source)
278
310
  if req.stream:
279
311
  return StreamingResponse(
280
312
  single_text_stream(answer),
@@ -332,9 +364,8 @@ def create_chat_router(
332
364
  answer = f"현재 페이지 URL: {req.client_url}"
333
365
  save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
334
366
  save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
335
- if req.source != "telegram":
336
- asyncio.create_task(broadcast_web_chat("user", req.message))
337
- asyncio.create_task(broadcast_web_chat("assistant", answer))
367
+ notify_chat_message("user", req.message, req.source)
368
+ notify_chat_message("assistant", answer, req.source)
338
369
  if req.stream:
339
370
  return StreamingResponse(
340
371
  single_text_stream(answer),
@@ -364,32 +395,38 @@ def create_chat_router(
364
395
 
365
396
  lang = detect_language(req.message)
366
397
  context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
398
+ # v4 Context System: one budgeted, provenance-carrying assembly
399
+ # (workspace memories + hybrid search + garden notes) replaces the
400
+ # ad-hoc vault-scan + LIKE-search concatenation. The trace records
401
+ # why each section is in the prompt.
402
+ context_trace = None
367
403
  try:
368
- knowledge_context = gardener.get_relevant_context(req.message)
369
- if knowledge_context:
370
- context += f"\n\n[LOCAL KNOWLEDGE BASE]\n{knowledge_context}"
371
- print(f"📖 Context reinforced with local knowledge.")
404
+ if context_assembler is not None:
405
+ assembled = context_assembler.assemble(
406
+ req.message,
407
+ user_email=effective_email,
408
+ conversation_id=req.conversation_id,
409
+ budget=2000,
410
+ )
411
+ context_trace = assembled.trace()
412
+ if assembled.text:
413
+ context += "\n\n" + assembled.text
372
414
  except Exception as e:
373
- logging.warning("Knowledge reinforcement skipped: %s", e)
374
-
415
+ logging.warning("Context assembly skipped: %s", e)
416
+
375
417
  is_doc_gen = detect_document_intent(req.message)
376
418
  doc_gen_context_result = None
377
-
419
+
378
420
  try:
379
- if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
380
- if is_doc_gen:
381
- doc_gen_context_result = retrieve_context_for_generation(
382
- KNOWLEDGE_GRAPH, req.message, max_results=10, max_hops=2,
383
- )
384
- graph_md = doc_gen_context_result.get("context_markdown", "")
385
- if graph_md:
386
- context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
387
- print("📝 Document generation context retrieved from knowledge graph.")
388
- else:
389
- graph_context = KNOWLEDGE_GRAPH.context_for_query(req.message)
390
- if graph_context:
391
- context += f"\n\n[KNOWLEDGE GRAPH]\n{graph_context}"
392
- print("🕸️ Context reinforced with knowledge graph.")
421
+ if ENABLE_GRAPH and KNOWLEDGE_GRAPH and is_doc_gen:
422
+ # Specialized multi-hop retrieval for document generation.
423
+ doc_gen_context_result = retrieve_context_for_generation(
424
+ KNOWLEDGE_GRAPH, req.message, max_results=10, max_hops=2,
425
+ )
426
+ graph_md = doc_gen_context_result.get("context_markdown", "")
427
+ if graph_md:
428
+ context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
429
+ print("📝 Document generation context retrieved from knowledge graph.")
393
430
  except Exception as e:
394
431
  logging.warning("Knowledge graph reinforcement skipped: %s", e)
395
432
 
@@ -416,11 +453,14 @@ def create_chat_router(
416
453
  KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
417
454
  context,
418
455
  )
456
+ if context_trace is not None and isinstance(trace_seed, dict):
457
+ # Persisted with the answer trace: 'why is this in my context?'
458
+ # is answerable from the stored record (UI surface lands in T9b).
459
+ trace_seed["context_assembly"] = context_trace
419
460
 
420
461
  history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
421
462
  save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
422
- if req.source != "telegram":
423
- asyncio.create_task(broadcast_web_chat("user", req.message))
463
+ notify_chat_message("user", req.message, req.source)
424
464
 
425
465
  if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
426
466
  conv_key = req.conversation_id or "default"
@@ -456,8 +496,7 @@ def create_chat_router(
456
496
  user_email=effective_email,
457
497
  trace=trace_seed,
458
498
  )
459
- if req.source != "telegram":
460
- asyncio.create_task(broadcast_web_chat("assistant", full_text))
499
+ notify_chat_message("assistant", full_text, req.source)
461
500
  yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
462
501
  yield "data: [DONE]\n\n"
463
502
  return StreamingResponse(
@@ -482,8 +521,7 @@ def create_chat_router(
482
521
  user_email=effective_email,
483
522
  trace=trace_seed,
484
523
  )
485
- if req.source != "telegram":
486
- asyncio.create_task(broadcast_web_chat("assistant", str(result)))
524
+ notify_chat_message("assistant", str(result), req.source)
487
525
  return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
488
526
 
489
527
  if req.stream:
@@ -519,8 +557,7 @@ def create_chat_router(
519
557
  user_email=effective_email,
520
558
  trace=trace_seed,
521
559
  )
522
- if req.source != "telegram":
523
- asyncio.create_task(broadcast_web_chat("assistant", str(result)))
560
+ notify_chat_message("assistant", str(result), req.source)
524
561
 
525
562
  return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
526
563
 
@@ -627,8 +664,7 @@ def create_chat_router(
627
664
  context,
628
665
  ),
629
666
  )
630
- if req.source != "telegram":
631
- asyncio.create_task(broadcast_web_chat("assistant", full_response))
667
+ notify_chat_message("assistant", full_response, req.source)
632
668
  yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
633
669
  yield "data: [DONE]\n\n"
634
670
 
@@ -751,7 +787,7 @@ def create_chat_router(
751
787
  user_email=current_user or None,
752
788
  timeline=ctx.transcript,
753
789
  relationships=["agent:planner", "agent:reviewer"],
754
- graph=_workspace_graph(),
790
+ graph=workspace_graph(),
755
791
  )
756
792
  except Exception as exc:
757
793
  logging.warning("workspace agent run record failed: %s", exc)
@@ -8,7 +8,7 @@ endpoints (install / verify-cloud / pull-model) stay in server_app for now.
8
8
  from __future__ import annotations
9
9
 
10
10
  import asyncio
11
- from typing import Any, Callable, List, Optional
11
+ from typing import Callable, List, Optional
12
12
 
13
13
  from fastapi import APIRouter, Request
14
14
 
@@ -8,7 +8,7 @@ the API.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from typing import Any, Callable, List, Optional
11
+ from typing import Callable, List, Optional
12
12
 
13
13
  from fastapi import APIRouter, HTTPException, Request
14
14
  from pydantic import BaseModel
@@ -0,0 +1,100 @@
1
+ """Invitation API: create, list, and accept workspace invitations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Optional
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class InvitationCreateRequest(BaseModel):
12
+ email: Optional[str] = None
13
+ workspace_id: Optional[str] = None
14
+ role: str = "member"
15
+ expires_hours: int = 168
16
+
17
+
18
+ def create_invitations_router(
19
+ *,
20
+ invitation_store,
21
+ workspace_service,
22
+ require_admin: Callable,
23
+ require_user: Callable[[Request], str],
24
+ user_id_for_email: Callable[[Optional[str]], Optional[str]],
25
+ append_audit_event: Callable[..., None],
26
+ ) -> APIRouter:
27
+ router = APIRouter()
28
+
29
+ @router.get("/invitations")
30
+ async def list_invitations(request: Request):
31
+ require_admin(request)
32
+ return {"invitations": invitation_store.list()}
33
+
34
+ @router.post("/invitations")
35
+ async def create_invitation(req: InvitationCreateRequest, request: Request):
36
+ admin_email, _ = require_admin(request)
37
+ actor_id = user_id_for_email(admin_email)
38
+ if req.workspace_id:
39
+ try:
40
+ workspace_service.store._require_permission(
41
+ workspace_service.store._load_org(workspace_service.store.load_state(), req.workspace_id),
42
+ actor_id,
43
+ "manage_members",
44
+ )
45
+ except FileNotFoundError as exc:
46
+ raise HTTPException(status_code=404, detail=f"Workspace not found: {req.workspace_id}") from exc
47
+ except ValueError as exc:
48
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
49
+ except PermissionError as exc:
50
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
51
+ if req.role not in {"owner", "admin", "member", "viewer"}:
52
+ raise HTTPException(status_code=400, detail="unknown invitation role")
53
+ invitation = invitation_store.create(
54
+ email=req.email,
55
+ workspace_id=req.workspace_id,
56
+ role=req.role,
57
+ created_by=actor_id,
58
+ expires_hours=req.expires_hours,
59
+ )
60
+ append_audit_event(
61
+ "invitation_created",
62
+ user_email=admin_email,
63
+ invitation_id=invitation.get("id"),
64
+ workspace_id=req.workspace_id,
65
+ role=req.role,
66
+ )
67
+ return {"invitation": invitation}
68
+
69
+ @router.post("/invitations/{token}/accept")
70
+ async def accept_invitation(token: str, request: Request):
71
+ email = require_user(request)
72
+ user_id = user_id_for_email(email)
73
+ if not user_id:
74
+ raise HTTPException(status_code=401, detail="Authentication required")
75
+ try:
76
+ invitation = invitation_store.accept(token, accepted_by=user_id, email=email or None)
77
+ except FileNotFoundError as exc:
78
+ raise HTTPException(status_code=404, detail="Invitation not found") from exc
79
+ except PermissionError as exc:
80
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
81
+ workspace_id = invitation.get("workspace_id")
82
+ if workspace_id:
83
+ try:
84
+ workspace_service.add_member(
85
+ workspace_id,
86
+ user_id=user_id,
87
+ role=invitation.get("role") or "member",
88
+ actor=invitation.get("created_by"),
89
+ )
90
+ except Exception as exc:
91
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
92
+ append_audit_event(
93
+ "invitation_accepted",
94
+ user_email=email,
95
+ invitation_id=invitation.get("id"),
96
+ workspace_id=workspace_id,
97
+ )
98
+ return {"invitation": invitation}
99
+
100
+ return router
@@ -0,0 +1,139 @@
1
+ """Knowledge graph page and API routes.
2
+
3
+ Relocated from the root ``knowledge_graph_api.py`` in v4 (T2); the root module
4
+ remains as a deprecation shim. Route paths and response shapes are frozen by
5
+ ``tests/unit/test_knowledge_graph_router_parity.py`` — the ``/knowledge-graph/*``
6
+ data endpoints back the v3 SPA (Files, Hybrid Search, graph explorer).
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Dict, Optional
11
+
12
+ from fastapi import APIRouter, HTTPException, Request
13
+ from pydantic import BaseModel
14
+
15
+ from latticeai.api.ui_redirects import app_redirect
16
+
17
+
18
+ class KnowledgeGraphIngestRequest(BaseModel):
19
+ type: str
20
+ content: str = ""
21
+ role: Optional[str] = None
22
+ title: Optional[str] = None
23
+ source: Optional[str] = None
24
+ conversation_id: Optional[str] = None
25
+ user_email: Optional[str] = None
26
+ user_nickname: Optional[str] = None
27
+ metadata: Optional[Dict[str, Any]] = None
28
+
29
+
30
+ def create_knowledge_graph_router(
31
+ *,
32
+ get_graph: Callable[[], Any],
33
+ require_graph: Callable[[], None],
34
+ require_user: Callable[[Request], str],
35
+ static_dir: Path,
36
+ ) -> APIRouter:
37
+ router = APIRouter()
38
+
39
+ def graph():
40
+ require_graph()
41
+ return get_graph()
42
+
43
+ @router.get("/graph")
44
+ async def knowledge_graph_page(request: Request):
45
+ """Serve the interactive knowledge graph canvas UI."""
46
+ graph()
47
+ require_user(request)
48
+ return app_redirect("knowledge-graph", request)
49
+
50
+ @router.get("/knowledge-graph")
51
+ async def knowledge_graph_legacy_page(request: Request):
52
+ """Backward-compatible route for the graph page."""
53
+ graph()
54
+ require_user(request)
55
+ return app_redirect("knowledge-graph", request)
56
+
57
+ @router.post("/knowledge-graph/curate")
58
+ async def knowledge_graph_curate(request: Request):
59
+ require_user(request)
60
+ return graph().curate()
61
+
62
+ @router.get("/knowledge-graph/provenance/coverage")
63
+ async def knowledge_graph_provenance_coverage(request: Request):
64
+ require_user(request)
65
+ return graph().provenance_coverage()
66
+
67
+ @router.get("/knowledge-graph/stats")
68
+ async def knowledge_graph_stats(request: Request):
69
+ require_user(request)
70
+ return graph().stats()
71
+
72
+ @router.get("/knowledge-graph/schema")
73
+ async def knowledge_graph_schema(request: Request):
74
+ require_user(request)
75
+ stats = graph().stats()
76
+ return {
77
+ "legacy_schema_version": stats.get("schema_version"),
78
+ "v2_schema_available": stats.get("v2_schema_available"),
79
+ "v2": stats.get("v2"),
80
+ }
81
+
82
+ @router.get("/knowledge-graph/graph")
83
+ async def knowledge_graph_data(request: Request, limit: int = 300):
84
+ require_user(request)
85
+ return graph().graph(limit)
86
+
87
+ @router.get("/knowledge-graph/documents")
88
+ async def knowledge_graph_documents(request: Request, limit: int = 200):
89
+ """Ingested documents (uploads + indexed local docs) with index state.
90
+
91
+ Backs the Files view so uploaded content is visible end-to-end:
92
+ upload → Files → Knowledge Graph → Hybrid Search → Chat.
93
+ """
94
+ require_user(request)
95
+ return graph().list_documents(limit)
96
+
97
+ @router.get("/knowledge-graph/search")
98
+ async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
99
+ require_user(request)
100
+ if not q or not q.strip():
101
+ return {"query": q, "matches": []}
102
+ return graph().search(q, limit)
103
+
104
+ @router.get("/knowledge-graph/context")
105
+ async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
106
+ require_user(request)
107
+ return {"query": q, "context": graph().context_for_query(q, limit)}
108
+
109
+ @router.get("/knowledge-graph/neighbors/{node_id:path}")
110
+ async def knowledge_graph_neighbors(node_id: str, request: Request):
111
+ require_user(request)
112
+ if not node_id:
113
+ raise HTTPException(status_code=400, detail="node_id required")
114
+ return graph().neighbors(node_id)
115
+
116
+ @router.post("/knowledge-graph/ingest")
117
+ async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
118
+ current_user = require_user(request)
119
+ kg = graph()
120
+ event_type = (req.type or "").strip().lower()
121
+ if event_type not in {"message", "ai_response", "note"}:
122
+ raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
123
+ role = req.role or ("assistant" if event_type == "ai_response" else "user")
124
+ return kg.ingest_message(
125
+ role,
126
+ req.content,
127
+ user_email=req.user_email or current_user,
128
+ user_nickname=req.user_nickname,
129
+ source=req.source or "mcp",
130
+ conversation_id=req.conversation_id,
131
+ raw={
132
+ "type": req.type,
133
+ "title": req.title,
134
+ "content": req.content,
135
+ "metadata": req.metadata or {},
136
+ },
137
+ )
138
+
139
+ return router
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Request
14
14
  from fastapi.responses import FileResponse
15
15
  from pydantic import BaseModel
16
16
 
17
- from knowledge_graph_api import create_knowledge_graph_router
17
+ from latticeai.api.knowledge_graph import create_knowledge_graph_router
18
18
  from local_knowledge_api import create_local_knowledge_router
19
19
  from tools import local_list, local_read, local_write
20
20
 
@@ -17,11 +17,12 @@ from datetime import datetime
17
17
  from pathlib import Path
18
18
  from typing import Any, Callable, Dict, List, Optional
19
19
 
20
+ from latticeai.services.ingestion import IngestionItem
20
21
  from fastapi import APIRouter, HTTPException, Request
21
22
  from pydantic import BaseModel
22
23
 
23
- import mcp_registry
24
- from mcp_registry import (
24
+ import latticeai.core.mcp_registry as mcp_registry
25
+ from latticeai.core.mcp_registry import (
25
26
  _get_combined_registry,
26
27
  _fetch_skills_marketplace,
27
28
  _fetch_plugin_directory,
@@ -76,6 +77,7 @@ def create_mcp_router(
76
77
  tool_response: Callable[..., Any],
77
78
  require_graph: Callable[[], Any],
78
79
  knowledge_graph: Any,
80
+ ingestion_pipeline: Any,
79
81
  data_dir: Path,
80
82
  ) -> APIRouter:
81
83
  router = APIRouter()
@@ -290,7 +292,7 @@ def create_mcp_router(
290
292
  if not skill_md.exists():
291
293
  continue
292
294
  lines = skill_md.read_text(encoding="utf-8").splitlines()
293
- desc = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("description:")), "")
295
+ desc = next((ln.split(":", 1)[1].strip() for ln in lines if ln.startswith("description:")), "")
294
296
  comment = lines[0] if lines else ""
295
297
  if "anthropics/claude-plugins-official" in comment:
296
298
  source = "anthropic"
@@ -357,15 +359,25 @@ def create_mcp_router(
357
359
  args = req.args or {}
358
360
  if req.action == "knowledge_graph_ingest":
359
361
  _require_graph()
360
- return KNOWLEDGE_GRAPH.ingest_message(
361
- args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
362
- args.get("content") or "",
363
- user_email=args.get("user_email") or current_user,
364
- user_nickname=args.get("user_nickname"),
365
- source=args.get("source") or "mcp",
366
- conversation_id=args.get("conversation_id"),
367
- raw=args,
362
+ # v4: MCP messages enter the brain through the unified ingestion
363
+ # pipeline (provenance + hook lifecycle), not a direct store call.
364
+ owner = args.get("user_email") or current_user
365
+ result = ingestion_pipeline.ingest(
366
+ IngestionItem(
367
+ source_type="mcp_message",
368
+ text=args.get("content") or "",
369
+ owner=owner,
370
+ conversation_id=args.get("conversation_id"),
371
+ metadata={
372
+ "role": args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
373
+ "user_nickname": args.get("user_nickname"),
374
+ "source": args.get("source") or "mcp",
375
+ "raw": args,
376
+ },
377
+ ),
378
+ user_email=owner,
368
379
  )
380
+ return result.as_dict()
369
381
  if req.action == "knowledge_graph_search":
370
382
  _require_graph()
371
383
  return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
@@ -7,7 +7,7 @@ operations (prune / compact / rebuild / clear). Full paths in decorators.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from typing import Any, Callable, List, Optional
10
+ from typing import Callable, List, Optional
11
11
 
12
12
  from fastapi import APIRouter, HTTPException, Request
13
13
  from pydantic import BaseModel
@@ -264,7 +264,7 @@ def create_models_router(
264
264
 
265
265
  @router.post("/setup/set-api-key")
266
266
  async def set_api_key(req: SetApiKeyRequest, request: Request):
267
- from llm_router import OPENAI_COMPATIBLE_PROVIDERS
267
+ from latticeai.models.router import OPENAI_COMPATIBLE_PROVIDERS
268
268
  config = OPENAI_COMPATIBLE_PROVIDERS.get(req.provider)
269
269
  if not config:
270
270
  raise HTTPException(status_code=400, detail="알 수 없는 프로바이더입니다.")