ltcai 9.0.0 → 9.2.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 (189) hide show
  1. package/README.md +88 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +178 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +4 -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/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +28 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +316 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +50 -13
  71. package/latticeai/core/agent_prompts.py +5 -0
  72. package/latticeai/core/agent_registry.py +1 -5
  73. package/latticeai/core/config.py +23 -3
  74. package/latticeai/core/context_builder.py +21 -3
  75. package/latticeai/core/file_generation.py +451 -0
  76. package/latticeai/core/invitations.py +1 -4
  77. package/latticeai/core/io_utils.py +9 -1
  78. package/latticeai/core/legacy_compatibility.py +24 -3
  79. package/latticeai/core/marketplace.py +1 -1
  80. package/latticeai/core/plugins.py +15 -0
  81. package/latticeai/core/policy.py +1 -1
  82. package/latticeai/core/realtime.py +38 -15
  83. package/latticeai/core/sessions.py +7 -2
  84. package/latticeai/core/timeutil.py +10 -0
  85. package/latticeai/core/tool_registry.py +90 -18
  86. package/latticeai/core/users.py +1 -5
  87. package/latticeai/core/workspace_graph_trace.py +31 -5
  88. package/latticeai/core/workspace_memory.py +3 -1
  89. package/latticeai/core/workspace_os.py +18 -7
  90. package/latticeai/core/workspace_os_utils.py +0 -5
  91. package/latticeai/core/workspace_permissions.py +2 -1
  92. package/latticeai/core/workspace_plugins.py +1 -1
  93. package/latticeai/core/workspace_runs.py +14 -5
  94. package/latticeai/core/workspace_skills.py +1 -1
  95. package/latticeai/core/workspace_snapshots.py +2 -1
  96. package/latticeai/core/workspace_timeline.py +2 -1
  97. package/latticeai/integrations/telegram_bot.py +94 -43
  98. package/latticeai/models/router.py +130 -36
  99. package/latticeai/runtime/access_runtime.py +62 -4
  100. package/latticeai/runtime/automation_runtime.py +13 -7
  101. package/latticeai/runtime/brain_runtime.py +19 -7
  102. package/latticeai/runtime/chat_wiring.py +2 -0
  103. package/latticeai/runtime/config_runtime.py +58 -26
  104. package/latticeai/runtime/context_runtime.py +16 -4
  105. package/latticeai/runtime/hooks_runtime.py +1 -1
  106. package/latticeai/runtime/model_wiring.py +33 -5
  107. package/latticeai/runtime/namespace_runtime.py +62 -72
  108. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  109. package/latticeai/runtime/review_wiring.py +1 -1
  110. package/latticeai/runtime/router_registration.py +34 -1
  111. package/latticeai/runtime/security_runtime.py +110 -13
  112. package/latticeai/runtime/stages.py +27 -0
  113. package/latticeai/server_app.py +5 -1
  114. package/latticeai/services/architecture_readiness.py +74 -5
  115. package/latticeai/services/brain_automation.py +3 -26
  116. package/latticeai/services/chat_service.py +205 -15
  117. package/latticeai/services/local_knowledge.py +423 -0
  118. package/latticeai/services/memory_service.py +55 -21
  119. package/latticeai/services/model_engines.py +48 -33
  120. package/latticeai/services/model_errors.py +17 -0
  121. package/latticeai/services/model_loading.py +28 -25
  122. package/latticeai/services/model_runtime.py +228 -162
  123. package/latticeai/services/p_reinforce.py +22 -3
  124. package/latticeai/services/platform_runtime.py +83 -23
  125. package/latticeai/services/product_readiness.py +19 -17
  126. package/latticeai/services/review_queue.py +12 -0
  127. package/latticeai/services/router_context.py +1 -0
  128. package/latticeai/services/run_executor.py +4 -9
  129. package/latticeai/services/search_service.py +203 -28
  130. package/latticeai/services/tool_dispatch.py +28 -5
  131. package/latticeai/services/triggers.py +53 -4
  132. package/latticeai/services/upload_service.py +13 -2
  133. package/latticeai/setup/__init__.py +25 -0
  134. package/latticeai/setup/auto_setup.py +857 -0
  135. package/latticeai/setup/wizard.py +1264 -0
  136. package/latticeai/tools/__init__.py +278 -0
  137. package/{tools → latticeai/tools}/commands.py +67 -7
  138. package/{tools → latticeai/tools}/computer.py +1 -1
  139. package/{tools → latticeai/tools}/documents.py +1 -1
  140. package/{tools → latticeai/tools}/filesystem.py +3 -3
  141. package/latticeai/tools/knowledge.py +178 -0
  142. package/{tools → latticeai/tools}/local_files.py +1 -1
  143. package/{tools → latticeai/tools}/network.py +0 -1
  144. package/local_knowledge_api.py +4 -342
  145. package/package.json +22 -2
  146. package/scripts/brain_quality_eval.py +3 -1
  147. package/scripts/bump_version.py +3 -0
  148. package/scripts/capture_release_evidence.mjs +180 -0
  149. package/scripts/check_current_release_docs.mjs +142 -0
  150. package/scripts/check_i18n_literals.mjs +107 -31
  151. package/scripts/check_openapi_drift.mjs +56 -0
  152. package/scripts/export_openapi.py +67 -7
  153. package/scripts/i18n_literal_allowlist.json +22 -24
  154. package/scripts/run_integration_tests.mjs +72 -10
  155. package/scripts/validate_release_artifacts.py +49 -7
  156. package/scripts/wheel_smoke.py +5 -0
  157. package/setup_wizard.py +3 -1260
  158. package/src-tauri/Cargo.lock +1 -1
  159. package/src-tauri/Cargo.toml +1 -1
  160. package/src-tauri/tauri.conf.json +1 -1
  161. package/static/app/asset-manifest.json +11 -11
  162. package/static/app/assets/Act-C3dBrWE-.js +1 -0
  163. package/static/app/assets/Brain-DBYgdcjt.js +321 -0
  164. package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
  165. package/static/app/assets/Library-CFfkNn3s.js +1 -0
  166. package/static/app/assets/System-BOurbT-v.js +1 -0
  167. package/static/app/assets/index-A3M9sElj.js +17 -0
  168. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  169. package/static/app/assets/primitives-DcUUmhdC.js +1 -0
  170. package/static/app/assets/textarea-BklR6zN4.js +1 -0
  171. package/static/app/index.html +2 -2
  172. package/static/sw.js +1 -1
  173. package/tools/__init__.py +21 -269
  174. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  175. package/latticeai/runtime/app_context_runtime.py +0 -13
  176. package/latticeai/runtime/tail_wiring.py +0 -21
  177. package/scripts/com.pts.claudecode.discord.plist +0 -31
  178. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  179. package/scripts/start-pts-claudecode-discord.sh +0 -51
  180. package/static/app/assets/Act-21lIXx2E.js +0 -1
  181. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  182. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  183. package/static/app/assets/Library-bFMtyni3.js +0 -1
  184. package/static/app/assets/System-K6krGCqn.js +0 -1
  185. package/static/app/assets/index-C4R3ws30.js +0 -17
  186. package/static/app/assets/index-ChSeOB02.css +0 -2
  187. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  188. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  189. package/tools/knowledge.py +0 -95
@@ -0,0 +1,356 @@
1
+ """HTTP adapter for the local chat agent runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import re
9
+ import secrets
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any, Dict
14
+
15
+ from fastapi import APIRouter, HTTPException, Request
16
+
17
+ from lattice_brain.runtime.hooks import dispatch_tool
18
+ from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
19
+ from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
20
+ from latticeai.core.agent import AgentRunContext, AgentState
21
+ from latticeai.services.tool_dispatch import collect_created_files
22
+
23
+
24
+ class AgentHTTPController:
25
+ def __init__(
26
+ self,
27
+ *,
28
+ runtime: Any,
29
+ model_router: Any,
30
+ require_user: Any,
31
+ require_admin: Any,
32
+ enforce_rate_limit: Any,
33
+ authenticated_identity: Any,
34
+ write_workspace: Any,
35
+ save_to_history: Any,
36
+ workspace_store: Any,
37
+ workspace_graph: Any,
38
+ hooks: Any,
39
+ execute_tool: Any,
40
+ base_dir: Path,
41
+ agent_root: Path,
42
+ ensure_agent_root: Any,
43
+ ) -> None:
44
+ self.runtime = runtime
45
+ self.model_router = model_router
46
+ self.require_user = require_user
47
+ self.require_admin = require_admin
48
+ self.enforce_rate_limit = enforce_rate_limit
49
+ self.authenticated_identity = authenticated_identity
50
+ self.write_workspace = write_workspace
51
+ self.save_to_history = save_to_history
52
+ self.workspace_store = workspace_store
53
+ self.workspace_graph = workspace_graph
54
+ self.hooks = hooks
55
+ self.execute_tool = execute_tool
56
+ self.base_dir = Path(base_dir)
57
+ self.agent_root = Path(agent_root)
58
+ self.ensure_agent_root = ensure_agent_root
59
+ self._pending: Dict[str, tuple] = {}
60
+ self._pending_lock = threading.Lock()
61
+ self._pending_ttl_seconds = 15 * 60
62
+ self._background_tasks: set[asyncio.Task] = set()
63
+
64
+ def register_routes(self, router: APIRouter) -> None:
65
+ @router.post("/agent/eval")
66
+ async def agent_eval(req: AgentEvalRequest, request: Request):
67
+ return await self.eval(req, request)
68
+
69
+ @router.post("/agent")
70
+ async def agent(req: AgentRequest, request: Request):
71
+ return await self.agent(req, request)
72
+
73
+ @router.post("/agent/resume")
74
+ async def agent_resume(req: AgentResumeRequest, request: Request):
75
+ return await self.resume(req, request)
76
+
77
+ def _schedule_background_task(self, coro: Any) -> None:
78
+ task = asyncio.create_task(coro)
79
+ self._background_tasks.add(task)
80
+
81
+ def finish(done: asyncio.Task) -> None:
82
+ self._background_tasks.discard(done)
83
+ try:
84
+ done.result()
85
+ except asyncio.CancelledError:
86
+ pass
87
+ except Exception as exc:
88
+ logging.warning("background chat task failed: %s", exc)
89
+
90
+ task.add_done_callback(finish)
91
+
92
+ async def eval(self, req: AgentEvalRequest, request: Request) -> Dict[str, Any]:
93
+ """Run a skill's schema.json eval cases."""
94
+ if self.require_admin is not None:
95
+ self.require_admin(request)
96
+ else:
97
+ self.require_user(request)
98
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", req.skill):
99
+ raise HTTPException(status_code=400, detail="Invalid skill name.")
100
+ skills_root = (self.base_dir / "skills").resolve()
101
+ skill_dir = (skills_root / req.skill).resolve()
102
+ if skill_dir.parent != skills_root:
103
+ raise HTTPException(status_code=400, detail="Invalid skill path.")
104
+ schema_path = skill_dir / "schema.json"
105
+ if not schema_path.exists():
106
+ raise HTTPException(
107
+ status_code=404,
108
+ detail=f"Skill '{req.skill}' not found or missing schema.json",
109
+ )
110
+
111
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
112
+ eval_cases = schema.get("evals", [])
113
+ if req.case_id:
114
+ eval_cases = [case for case in eval_cases if case.get("id") == req.case_id]
115
+ if not eval_cases:
116
+ return {
117
+ "skill": req.skill,
118
+ "total": 0,
119
+ "passed": 0,
120
+ "failed": 0,
121
+ "results": [],
122
+ "message": "No eval cases defined in schema.json",
123
+ }
124
+
125
+ action_name = schema.get("action", req.skill)
126
+ results = []
127
+ for case in eval_cases:
128
+ case_id = case.get("id", "?")
129
+ try:
130
+ case_input = case.get("input", {})
131
+ result = dispatch_tool(
132
+ self.hooks,
133
+ action_name,
134
+ case_input,
135
+ lambda: self.execute_tool(action_name, case_input),
136
+ source="eval",
137
+ )
138
+ criteria = case.get("pass_criteria", "")
139
+ if "success == true" in criteria:
140
+ passed = result.get("success") is True
141
+ elif "success == false" in criteria:
142
+ passed = result.get("success") is False
143
+ else:
144
+ passed = True
145
+ results.append(
146
+ {
147
+ "id": case_id,
148
+ "description": case.get("description", ""),
149
+ "passed": passed,
150
+ "result": result,
151
+ "pass_criteria": criteria,
152
+ }
153
+ )
154
+ except Exception as exc:
155
+ results.append(
156
+ {
157
+ "id": case_id,
158
+ "description": case.get("description", ""),
159
+ "passed": False,
160
+ "error": str(exc),
161
+ "pass_criteria": case.get("pass_criteria", ""),
162
+ }
163
+ )
164
+ passed_count = sum(1 for result in results if result.get("passed") is True)
165
+ return {
166
+ "skill": req.skill,
167
+ "action": action_name,
168
+ "total": len(results),
169
+ "passed": passed_count,
170
+ "failed": len(results) - passed_count,
171
+ "results": results,
172
+ }
173
+
174
+ async def agent(self, req: AgentRequest, request: Request) -> Dict[str, Any]:
175
+ """Plan and execute a natural-language local agent run."""
176
+ current_user = self.require_user(request)
177
+ self.enforce_rate_limit(current_user, "agent")
178
+ effective_email = self.authenticated_identity(current_user, req.user_email)
179
+ header_workspace = workspace_scope_from_request(request)
180
+ if req.workspace_id and header_workspace and req.workspace_id != header_workspace:
181
+ raise HTTPException(
182
+ status_code=403,
183
+ detail="workspace_id must match X-Workspace-Id.",
184
+ )
185
+ req.workspace_id = self.write_workspace(
186
+ req.workspace_id or header_workspace,
187
+ current_user,
188
+ )
189
+ req.user_email = effective_email
190
+ if not self.model_router.current_model_id:
191
+ raise HTTPException(
192
+ status_code=400,
193
+ detail="No model loaded. Call /models/load first.",
194
+ )
195
+
196
+ self.ensure_agent_root()
197
+ language_hint = _LANG_HINT[detect_language(req.message)]
198
+ max_steps = max(1, min(req.max_steps, 50))
199
+ max_retry = 3
200
+ ctx = AgentRunContext()
201
+ ctx.executing_model = req.executing_model
202
+ ctx.reviewing_model = req.reviewing_model
203
+ ctx.state = AgentState.PLANNING
204
+ ctx.state_history.append(ctx.state.value)
205
+ await self.runtime.plan(
206
+ ctx,
207
+ req,
208
+ language_hint,
209
+ current_user,
210
+ model_id=req.planning_model,
211
+ )
212
+
213
+ if req.human_in_loop:
214
+ context_id = secrets.token_urlsafe(16)
215
+ with self._pending_lock:
216
+ self._pending[context_id] = (
217
+ ctx,
218
+ req,
219
+ language_hint,
220
+ current_user,
221
+ time.monotonic(),
222
+ )
223
+ return {
224
+ "status": "waiting_approval",
225
+ "context_id": context_id,
226
+ "plan": ctx.plan,
227
+ "steps": ctx.transcript,
228
+ "state_history": ctx.state_history,
229
+ "planning_model": req.planning_model or self.model_router.current_model_id,
230
+ "executing_model": req.executing_model or self.model_router.current_model_id,
231
+ "reviewing_model": req.reviewing_model or self.model_router.current_model_id,
232
+ }
233
+
234
+ self.runtime.approve(ctx, current_user, approved_by_human=False)
235
+ return await self._finish(
236
+ ctx,
237
+ req,
238
+ language_hint,
239
+ current_user,
240
+ max_steps,
241
+ max_retry,
242
+ )
243
+
244
+ async def _finish(
245
+ self,
246
+ ctx: AgentRunContext,
247
+ req: AgentRequest,
248
+ language_hint: str,
249
+ current_user: str,
250
+ max_steps: int,
251
+ max_retry: int,
252
+ ) -> Dict[str, Any]:
253
+ await self.runtime.run_to_completion(
254
+ ctx,
255
+ req,
256
+ language_hint,
257
+ current_user,
258
+ max_steps,
259
+ max_retry,
260
+ )
261
+ self._schedule_background_task(self.runtime.memory_update(ctx, req, current_user))
262
+
263
+ message = ctx.final_message or "작업을 완료했습니다."
264
+ history_user = {
265
+ "user_email": req.user_email or current_user or None,
266
+ "user_nickname": req.user_nickname,
267
+ }
268
+ await asyncio.to_thread(
269
+ self.save_to_history,
270
+ "user",
271
+ req.message,
272
+ **history_user,
273
+ source=req.source or "web",
274
+ conversation_id=req.conversation_id,
275
+ workspace_id=req.workspace_id,
276
+ )
277
+ await asyncio.to_thread(
278
+ self.save_to_history,
279
+ "assistant",
280
+ message,
281
+ **history_user,
282
+ source=req.source or "web",
283
+ conversation_id=req.conversation_id,
284
+ workspace_id=req.workspace_id,
285
+ )
286
+ try:
287
+ self.workspace_store.record_agent_run(
288
+ agent_id="agent:executor",
289
+ status="ok" if ctx.state == AgentState.DONE else "failed",
290
+ input_text=req.message,
291
+ output_text=message,
292
+ user_email=current_user or None,
293
+ workspace_id=req.workspace_id,
294
+ mode="llm",
295
+ timeline=ctx.transcript,
296
+ relationships=["agent:planner", "agent:reviewer"],
297
+ graph=self.workspace_graph(),
298
+ )
299
+ except Exception as exc:
300
+ logging.warning("workspace agent run record failed: %s", exc)
301
+ return {
302
+ "status": "ok" if ctx.state == AgentState.DONE else "failed",
303
+ "response": message,
304
+ "workspace": str(self.agent_root),
305
+ "steps": ctx.transcript,
306
+ "state_history": ctx.state_history,
307
+ "final_state": ctx.state.value,
308
+ "created_files": collect_created_files(ctx.transcript),
309
+ }
310
+
311
+ async def resume(
312
+ self,
313
+ req: AgentResumeRequest,
314
+ request: Request,
315
+ ) -> Dict[str, Any]:
316
+ """Resume a paused agent after human approval of the plan."""
317
+ current_user = self.require_user(request)
318
+ with self._pending_lock:
319
+ now = time.monotonic()
320
+ for context_id, pending in list(self._pending.items()):
321
+ if now - pending[4] >= self._pending_ttl_seconds:
322
+ self._pending.pop(context_id, None)
323
+ entry = self._pending.get(req.context_id)
324
+ if entry and entry[3] == current_user:
325
+ self._pending.pop(req.context_id, None)
326
+ if not entry:
327
+ raise HTTPException(
328
+ status_code=404,
329
+ detail="Agent context not found or expired. Start a new request.",
330
+ )
331
+
332
+ ctx, original_request, language_hint, original_user, _created_at = entry
333
+ if original_user != current_user:
334
+ raise HTTPException(
335
+ status_code=403,
336
+ detail="Agent context belongs to another user.",
337
+ )
338
+ if not req.approved:
339
+ return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
340
+ if req.modified_plan:
341
+ ctx.plan = req.modified_plan
342
+ ctx.transcript[-1].update(ctx.plan)
343
+ ctx.executing_model = req.executing_model or ctx.executing_model
344
+ ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
345
+ self.runtime.approve(ctx, current_user)
346
+ return await self._finish(
347
+ ctx,
348
+ original_request,
349
+ language_hint,
350
+ current_user,
351
+ max(1, min(original_request.max_steps, 50)),
352
+ 3,
353
+ )
354
+
355
+
356
+ __all__ = ["AgentHTTPController"]
@@ -0,0 +1,54 @@
1
+ """Stable request contracts shared by the chat HTTP submodules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class ChatRequest(BaseModel):
11
+ message: str
12
+ conversation_id: Optional[str] = None
13
+ client_url: Optional[str] = None
14
+ model: Optional[str] = None
15
+ max_tokens: int = 2048
16
+ temperature: float = 0.2
17
+ stream: bool = True
18
+ context: Optional[str] = None
19
+ source: Optional[str] = None
20
+ user_email: Optional[str] = None
21
+ user_nickname: Optional[str] = None
22
+ image_data: Optional[str] = None
23
+ allow_file_context: bool = False
24
+
25
+
26
+ class AgentRequest(BaseModel):
27
+ message: str
28
+ conversation_id: Optional[str] = None
29
+ source: Optional[str] = None
30
+ max_steps: int = 25
31
+ temperature: float = 0.1
32
+ user_email: Optional[str] = None
33
+ user_nickname: Optional[str] = None
34
+ workspace_id: Optional[str] = None
35
+ planning_model: Optional[str] = None
36
+ executing_model: Optional[str] = None
37
+ reviewing_model: Optional[str] = None
38
+ human_in_loop: bool = False
39
+
40
+
41
+ class AgentResumeRequest(BaseModel):
42
+ context_id: str
43
+ approved: bool = True
44
+ modified_plan: Optional[dict] = None
45
+ executing_model: Optional[str] = None
46
+ reviewing_model: Optional[str] = None
47
+
48
+
49
+ class AgentEvalRequest(BaseModel):
50
+ skill: str
51
+ case_id: Optional[str] = None
52
+
53
+
54
+ __all__ = ["AgentEvalRequest", "AgentRequest", "AgentResumeRequest", "ChatRequest"]
@@ -0,0 +1,256 @@
1
+ """Screenshot ingestion and Knowledge-Graph-backed document generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import json
8
+ import logging
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Dict, Optional
15
+
16
+ from fastapi.responses import JSONResponse, StreamingResponse
17
+ from PIL import Image
18
+
19
+ from latticeai.core.context_builder import (
20
+ format_sources_footnote,
21
+ retrieve_context_for_generation,
22
+ )
23
+ from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
24
+
25
+
26
+ def extract_screenshot_context(image_data: Optional[str]) -> str:
27
+ if not image_data:
28
+ return ""
29
+
30
+ lines = ["[SCREENSHOT INGESTION]"]
31
+ try:
32
+ image_bytes = base64.b64decode(image_data)
33
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
34
+ lines.append(f"- image_size: {image.width}x{image.height}")
35
+ lines.append(f"- image_mode: {image.mode}")
36
+ except Exception as exc:
37
+ lines.append(f"- image_decode_error: {exc}")
38
+ return "\n".join(lines)
39
+
40
+ tesseract_path = shutil.which("tesseract")
41
+ if not tesseract_path:
42
+ lines.append("- ocr: unavailable; install `tesseract` to enable OCR text extraction.")
43
+ return "\n".join(lines)
44
+
45
+ temp_path = None
46
+ try:
47
+ with tempfile.NamedTemporaryFile(
48
+ prefix="ltcai-screenshot-",
49
+ suffix=".png",
50
+ delete=False,
51
+ ) as temp:
52
+ temp.write(image_bytes)
53
+ temp_path = temp.name
54
+
55
+ ocr_text = ""
56
+ for language in ("kor+eng", "eng"):
57
+ completed = subprocess.run(
58
+ [tesseract_path, temp_path, "stdout", "-l", language, "--psm", "6"],
59
+ capture_output=True,
60
+ text=True,
61
+ timeout=20,
62
+ check=False,
63
+ )
64
+ if completed.returncode == 0 and completed.stdout.strip():
65
+ ocr_text = completed.stdout.strip()
66
+ lines.append(f"- ocr_language: {language}")
67
+ break
68
+
69
+ if ocr_text:
70
+ lines.extend(("- ocr_text:", ocr_text[:4000]))
71
+ else:
72
+ lines.append("- ocr: no text extracted.")
73
+ except Exception as exc:
74
+ lines.append(f"- ocr_error: {exc}")
75
+ finally:
76
+ if temp_path:
77
+ try:
78
+ Path(temp_path).unlink()
79
+ except OSError:
80
+ pass
81
+ return "\n".join(lines)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class DocumentPreparation:
86
+ is_document: bool
87
+ context: str
88
+ retrieval: Optional[Dict[str, Any]]
89
+
90
+
91
+ class DocumentGenerationCoordinator:
92
+ """Own per-conversation document sessions and generation finalization."""
93
+
94
+ def __init__(
95
+ self,
96
+ *,
97
+ model_router: Any,
98
+ knowledge_graph: Any,
99
+ enable_graph: bool,
100
+ chat_service: Any,
101
+ notify: Any,
102
+ ) -> None:
103
+ self._router = model_router
104
+ self._graph = knowledge_graph
105
+ self._enable_graph = bool(enable_graph)
106
+ self._chat_service = chat_service
107
+ self._notify = notify
108
+ self._sessions: Dict[tuple[str, str, str], DocumentGenerationSession] = {}
109
+
110
+ def prepare(
111
+ self,
112
+ req: Any,
113
+ context: str,
114
+ *,
115
+ workspace_id: Optional[str],
116
+ ) -> DocumentPreparation:
117
+ is_document = detect_document_intent(req.message)
118
+ retrieval = None
119
+ if self._enable_graph and self._graph and is_document:
120
+ try:
121
+ retrieval = retrieve_context_for_generation(
122
+ self._graph,
123
+ req.message,
124
+ max_results=10,
125
+ max_hops=2,
126
+ allowed_workspaces={workspace_id} if workspace_id else None,
127
+ )
128
+ graph_context = retrieval.get("context_markdown", "")
129
+ if graph_context:
130
+ context += (
131
+ "\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n"
132
+ + graph_context
133
+ )
134
+ logging.debug(
135
+ "Document generation context retrieved from knowledge graph."
136
+ )
137
+ except Exception as exc:
138
+ logging.warning("Knowledge graph reinforcement skipped: %s", exc)
139
+ return DocumentPreparation(is_document, context, retrieval)
140
+
141
+ async def response(
142
+ self,
143
+ req: Any,
144
+ preparation: DocumentPreparation,
145
+ *,
146
+ model_id: str,
147
+ effective_email: Optional[str],
148
+ workspace_id: Optional[str],
149
+ history_meta: Dict[str, Any],
150
+ trace_seed: Dict[str, Any],
151
+ ):
152
+ if not (
153
+ preparation.is_document
154
+ and self._enable_graph
155
+ and self._graph
156
+ ):
157
+ return None
158
+
159
+ key = (
160
+ effective_email or "",
161
+ workspace_id or "",
162
+ req.conversation_id or "default",
163
+ )
164
+ session = self._sessions.setdefault(key, DocumentGenerationSession())
165
+ graph_markdown = (preparation.retrieval or {}).get("context_markdown", "")
166
+ system_prompt = session.get_system_prompt(graph_markdown)
167
+ footnote = format_sources_footnote((preparation.retrieval or {}).get("sources", []))
168
+
169
+ if req.stream:
170
+ async def stream_document():
171
+ collected = []
172
+ stream_error = None
173
+ try:
174
+ async for chunk in self._router.stream_generate_document_as(
175
+ model_id,
176
+ req.message,
177
+ system_prompt,
178
+ max_tokens=req.max_tokens or 8192,
179
+ temperature=req.temperature or 0.3,
180
+ ):
181
+ collected.append(chunk)
182
+ yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n"
183
+ except Exception as exc:
184
+ stream_error = str(exc)
185
+ logging.warning("document stream failed: %s", exc)
186
+ yield f"data: {json.dumps({'error': stream_error}, ensure_ascii=False)}\n\n"
187
+
188
+ full_text = "".join(collected)
189
+ if footnote:
190
+ yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
191
+ full_text += footnote
192
+ if stream_error:
193
+ full_text = (
194
+ f"{full_text}\n\n[stream_error] {stream_error}"
195
+ if full_text
196
+ else f"[stream_error] {stream_error}"
197
+ )
198
+ session.update(graph_markdown, full_text, req.conversation_id)
199
+ trace_record = await self._chat_service.persist_answer(
200
+ question=req.message,
201
+ response=full_text,
202
+ conversation_id=req.conversation_id,
203
+ user_email=effective_email,
204
+ user_nickname=req.user_nickname,
205
+ source=req.source,
206
+ trace=trace_seed,
207
+ workspace_id=workspace_id,
208
+ history_meta=history_meta,
209
+ notify=self._notify,
210
+ )
211
+ yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
212
+ yield "data: [DONE]\n\n"
213
+
214
+ return StreamingResponse(
215
+ stream_document(),
216
+ media_type="text/event-stream",
217
+ headers={"X-Model": model_id, "X-Doc-Gen": "true"},
218
+ )
219
+
220
+ result = await self._router.generate_document_as(
221
+ model_id,
222
+ req.message,
223
+ system_prompt,
224
+ max_tokens=req.max_tokens or 8192,
225
+ temperature=req.temperature or 0.3,
226
+ )
227
+ if footnote:
228
+ result += footnote
229
+ response_text = str(result)
230
+ session.update(graph_markdown, response_text, req.conversation_id)
231
+ trace_record = await self._chat_service.persist_answer(
232
+ question=req.message,
233
+ response=response_text,
234
+ conversation_id=req.conversation_id,
235
+ user_email=effective_email,
236
+ user_nickname=req.user_nickname,
237
+ source=req.source,
238
+ trace=trace_seed,
239
+ workspace_id=workspace_id,
240
+ history_meta=history_meta,
241
+ notify=self._notify,
242
+ )
243
+ return JSONResponse(
244
+ content={
245
+ "response": response_text,
246
+ "trace_id": trace_record["id"],
247
+ "trace": trace_record,
248
+ }
249
+ )
250
+
251
+
252
+ __all__ = [
253
+ "DocumentGenerationCoordinator",
254
+ "DocumentPreparation",
255
+ "extract_screenshot_context",
256
+ ]