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
@@ -1,36 +1,31 @@
1
- """Chat, history, and local agent API routes."""
1
+ """Composition root for chat, history, document, and local-agent routes.
2
+
3
+ The stable public factory remains ``create_chat_router(AppContext)``. Feature
4
+ implementation is split across focused HTTP modules while this file owns only
5
+ dependency assembly and the top-level chat request pipeline.
6
+ """
2
7
 
3
8
  from __future__ import annotations
4
9
 
5
- import asyncio
6
- import base64
7
- import io
8
- import json
9
10
  import logging
10
11
  import re
11
- import secrets
12
- import shutil
13
- import subprocess
14
- import tempfile
15
- import threading
16
12
  from pathlib import Path
17
- from typing import Any, AsyncIterator, Dict, Optional
13
+ from typing import Dict, Optional
18
14
 
19
15
  from fastapi import APIRouter, HTTPException, Request
20
16
  from fastapi.responses import JSONResponse, StreamingResponse
21
- from pydantic import BaseModel
22
- from PIL import Image
23
17
 
24
- from latticeai.core.agent import AgentRunContext, AgentState
25
- from latticeai.core.context_builder import format_sources_footnote, retrieve_context_for_generation
26
- from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
27
- from lattice_brain.runtime.hooks import dispatch_tool
28
- from latticeai.services.app_context import AppContext
29
- from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
30
- from tools import AGENT_ROOT, ToolError, ensure_agent_root, execute_tool, knowledge_save, network_status
31
- # Pure chat helpers (language/intent detection, file-action parsing, recent-context
32
- # assembly) live in chat_helpers; re-imported here so ``from latticeai.api.chat
33
- # import <helper>`` keeps resolving. See __all__ below.
18
+ from latticeai.api.chat_agent_http import AgentHTTPController
19
+ from latticeai.api.chat_contracts import (
20
+ AgentEvalRequest,
21
+ AgentRequest,
22
+ AgentResumeRequest,
23
+ ChatRequest,
24
+ )
25
+ from latticeai.api.chat_documents import (
26
+ DocumentGenerationCoordinator,
27
+ extract_screenshot_context,
28
+ )
34
29
  from latticeai.api.chat_helpers import (
35
30
  _LANG_HINT,
36
31
  build_recent_chat_context,
@@ -47,55 +42,27 @@ from latticeai.api.chat_helpers import (
47
42
  strip_generated_file_content,
48
43
  workspace_scope_from_request,
49
44
  )
50
-
51
- class ChatRequest(BaseModel):
52
- message: str
53
- conversation_id: Optional[str] = None
54
- client_url: Optional[str] = None
55
- model: Optional[str] = None
56
- max_tokens: int = 2048
57
- temperature: float = 0.2
58
- stream: bool = True
59
- context: Optional[str] = None
60
- source: Optional[str] = None
61
- user_email: Optional[str] = None
62
- user_nickname: Optional[str] = None
63
- image_data: Optional[str] = None
64
- allow_file_context: bool = False
65
-
66
-
67
- class AgentRequest(BaseModel):
68
- message: str
69
- conversation_id: Optional[str] = None
70
- source: Optional[str] = None
71
- max_steps: int = 25
72
- temperature: float = 0.1
73
- user_email: Optional[str] = None
74
- user_nickname: Optional[str] = None
75
- workspace_id: Optional[str] = None
76
- planning_model: Optional[str] = None
77
- executing_model: Optional[str] = None
78
- reviewing_model: Optional[str] = None
79
- human_in_loop: bool = False
80
-
81
-
82
- class AgentResumeRequest(BaseModel):
83
- context_id: str
84
- approved: bool = True
85
- modified_plan: Optional[dict] = None
86
- executing_model: Optional[str] = None
87
- reviewing_model: Optional[str] = None
88
-
89
-
90
- class AgentEvalRequest(BaseModel):
91
- skill: str
92
- case_id: Optional[str] = None
45
+ from latticeai.api.chat_history import HistoryRouteDependencies, register_history_routes
46
+ from latticeai.api.chat_intents import ChatIntentController
47
+ from latticeai.api.chat_stream import stream_chat
48
+ from latticeai.services.app_context import AppContext
49
+ from latticeai.services.chat_service import ChatService
50
+ from latticeai.services.tool_dispatch import build_agent_runtime, enforce_tool_policy
51
+ from latticeai.tools import (
52
+ AGENT_ROOT,
53
+ ToolError,
54
+ ensure_agent_root,
55
+ execute_tool,
56
+ knowledge_save,
57
+ network_status,
58
+ )
93
59
 
94
60
 
95
- # The chat helpers imported at the top of the module are re-exported so
96
- # ``from latticeai.api.chat import <helper>`` (tests, app_factory) keeps resolving
97
- # after the split; __all__ marks them as intentional re-exports.
98
61
  __all__ = [
62
+ "AgentEvalRequest",
63
+ "AgentRequest",
64
+ "AgentResumeRequest",
65
+ "ChatRequest",
99
66
  "create_chat_router",
100
67
  "build_recent_chat_context",
101
68
  "pair_user_history",
@@ -114,900 +81,403 @@ __all__ = [
114
81
 
115
82
 
116
83
  def create_chat_router(context: AppContext) -> APIRouter:
117
- """Build the chat/history/agent router from the typed application context.
84
+ """Build the unchanged chat/history/agent route surface."""
118
85
 
119
- Replaces the historical ~25-kwarg factory signature: ``context``
120
- (:class:`latticeai.services.app_context.AppContext`) carries the same
121
- dependencies as typed fields.
122
- """
123
86
  api_router = APIRouter()
124
- router = context.model_router
125
- hooks = context.hooks
126
- workspace_graph = context.workspace_graph
127
- context_assembler = context.context_assembler
87
+ model_router = context.model_router
88
+ config = context.config
128
89
  require_user = context.require_user
129
- enforce_rate_limit = context.enforce_rate_limit
130
- get_history_user = context.get_history_user
131
- save_to_history = context.save_to_history
132
- append_audit_event = context.append_audit_event
133
- clear_history = context.clear_history
134
- clear_conversation = context.clear_conversation
135
- get_history = context.get_history
136
- group_history_conversations = context.group_history_conversations
137
- get_conversation_messages = context.get_conversation_messages
138
- conversation_title = context.conversation_title
90
+ workspace_service = context.workspace_service
139
91
  allowed_workspaces_for = context.allowed_workspaces_for
140
92
 
141
- CONFIG = context.config
142
- CHAT_SERVICE = context.chat_service
143
- WORKSPACE_OS = context.workspace_store
144
- ENABLE_GRAPH = context.enable_graph
145
- KNOWLEDGE_GRAPH = context.knowledge_graph
146
- PUBLIC_MODEL = context.public_model
147
- BASE_DIR = context.base_dir
148
- _doc_gen_sessions: dict = {}
149
- _pending_agents: dict[str, tuple] = {}
150
- _pending_agents_lock = threading.Lock()
151
- _background_tasks: set[asyncio.Task] = set()
152
-
153
- on_chat_message = context.on_chat_message
93
+ chat_service = ChatService.coerce(
94
+ context.chat_service,
95
+ store=context.workspace_store,
96
+ get_history=context.get_history,
97
+ save_to_history=context.save_to_history,
98
+ get_history_user=context.get_history_user,
99
+ )
154
100
 
155
101
  def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
156
- """Mirror a chat exchange to the injected external bridge, if any.
157
-
158
- ``on_chat_message`` is registered by ``create_app`` only when
159
- ENABLE_TELEGRAM is truthy; exchanges that originated *from* telegram
160
- are never echoed back. No bridge registered → no-op.
161
- """
162
- if on_chat_message is None or source == "telegram":
102
+ """Mirror persisted web exchanges to an injected bridge, never echoing it."""
103
+ if context.on_chat_message is None or source == "telegram":
163
104
  return
164
105
  try:
165
- on_chat_message(role, text, source)
106
+ context.on_chat_message(role, text, source)
166
107
  except Exception as exc:
167
108
  logging.warning("chat message bridge failed: %s", exc)
168
109
 
169
- def schedule_background_task(coro) -> None:
170
- task = asyncio.create_task(coro)
171
- _background_tasks.add(task)
172
-
173
- def _finish(done: asyncio.Task) -> None:
174
- _background_tasks.discard(done)
175
- try:
176
- done.result()
177
- except asyncio.CancelledError:
178
- pass
179
- except Exception as exc:
180
- logging.warning("background chat task failed: %s", exc)
181
-
182
- task.add_done_callback(_finish)
183
-
184
- async def save_history_entry(role: str, content: str, history_meta: Dict, history_user: Dict) -> None:
185
- await asyncio.to_thread(save_to_history, role, content, **history_meta, **history_user)
186
-
187
- async def persist_chat_exchange(
188
- req: ChatRequest,
189
- answer: str,
190
- *,
191
- history_meta: Dict,
192
- history_user: Dict,
193
- user_message: Optional[str] = None,
194
- ) -> None:
195
- message = user_message if user_message is not None else req.message
196
- await save_history_entry("user", message, history_meta, history_user)
197
- await save_history_entry("assistant", answer, history_meta, history_user)
198
- notify_chat_message("user", req.message, req.source)
199
- notify_chat_message("assistant", answer, req.source)
200
-
201
- def no_model_response() -> JSONResponse:
202
- detail = "No model loaded. Call /models/load first."
203
- if CONFIG.is_public:
204
- detail = f"No public model loaded. Set OPENAI_API_KEY and LATTICEAI_PUBLIC_MODEL={PUBLIC_MODEL}, or call /models/load with an OpenAI-compatible model."
205
- return JSONResponse(
206
- status_code=400,
207
- content={
208
- "error": "no_model_loaded",
209
- "detail": detail,
210
- "message": detail,
211
- "action": "load_model",
212
- },
213
- )
214
-
215
- def single_answer_response(req: ChatRequest, answer: str, *, model: str) -> JSONResponse | StreamingResponse:
216
- if req.stream:
217
- return StreamingResponse(
218
- single_text_stream(answer),
219
- media_type="text/event-stream",
220
- headers={"X-Model": model},
221
- )
222
- return JSONResponse(content={"response": answer})
223
-
224
- def agent_payload_stream(answer: str, payload: Dict[str, Any]) -> AsyncIterator[str]:
225
- async def _stream() -> AsyncIterator[str]:
226
- yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
227
- yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
228
- yield "data: [DONE]\n\n"
229
-
230
- return _stream()
231
-
232
- async def handle_network_status_intent(
233
- req: ChatRequest,
234
- *,
235
- history_meta: Dict,
236
- history_user: Dict,
237
- ) -> JSONResponse | StreamingResponse:
238
- history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
239
- try:
240
- answer = format_network_status(network_status())
241
- except ToolError as exc:
242
- answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
243
- await persist_chat_exchange(req, answer, history_meta=history_meta, history_user=history_user, user_message=history_message)
244
- return single_answer_response(req, answer, model="network_status")
245
-
246
- async def handle_clear_intent(
247
- req: ChatRequest,
248
- *,
249
- effective_email: Optional[str],
250
- ) -> JSONResponse | StreamingResponse:
251
- command = req.message.strip().lower()
252
- clear_scope = "all" if command == "/clear_all" else "conversation"
253
- if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
254
- try:
255
- KNOWLEDGE_GRAPH.ingest_event(
256
- "ClearEvent",
257
- f"{command} requested",
258
- user_email=effective_email,
259
- user_nickname=req.user_nickname,
260
- source=req.source or "web",
261
- conversation_id=req.conversation_id,
262
- metadata={"command": command, "scope": clear_scope},
110
+ def authenticated_identity(
111
+ current_user: str,
112
+ claimed_email: Optional[str],
113
+ ) -> Optional[str]:
114
+ if current_user and claimed_email:
115
+ if current_user.strip().lower() != claimed_email.strip().lower():
116
+ raise HTTPException(
117
+ status_code=403,
118
+ detail="user_email must match the authenticated user.",
263
119
  )
264
- except Exception as e:
265
- logging.warning("knowledge graph clear event ingest failed: %s", e)
266
- if command == "/clear_all":
267
- result = clear_history(0, **history_scope_for_user(effective_email))
268
- answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
269
- elif req.conversation_id:
270
- result = clear_conversation(req.conversation_id, **history_scope_for_user(effective_email))
271
- answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
272
- else:
273
- result = clear_history(0, **history_scope_for_user(effective_email))
274
- answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
275
- append_audit_event(
276
- "clear_command",
277
- user_email=effective_email,
278
- user_nickname=req.user_nickname,
279
- source=req.source or "web",
280
- conversation_id=req.conversation_id,
281
- command=command,
282
- scope=clear_scope,
283
- removed=result.get("removed", 0),
284
- kept=result.get("kept", 0),
285
- )
286
- notify_chat_message("user", req.message, req.source)
287
- notify_chat_message("assistant", answer, req.source)
288
- return single_answer_response(req, answer, model="history")
289
-
290
- async def handle_current_url_intent(
291
- req: ChatRequest,
292
- *,
293
- history_meta: Dict,
294
- history_user: Dict,
295
- ) -> JSONResponse | StreamingResponse:
296
- answer = f"현재 페이지 URL: {req.client_url}"
297
- await persist_chat_exchange(req, answer, history_meta=history_meta, history_user=history_user)
298
- return single_answer_response(req, answer, model="client_url")
299
-
300
- async def handle_direct_file_action(req: ChatRequest) -> Optional[JSONResponse | StreamingResponse]:
301
- target_path = file_action_target(req.message)
302
- if not target_path:
303
- return None
304
- content = inline_file_action_content(req.message)
305
- if content is None and not router.current_model_id:
306
- return no_model_response()
307
- if content is None and router.current_model_id:
308
- if req.model and req.model != router.current_model_id:
309
- if req.model not in router.loaded_model_ids:
310
- raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
311
- router.switch_model(req.model)
312
- generation_context = (
313
- "Create the exact content for the requested file. "
314
- "Return only the file bytes as plain text. "
315
- "Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
316
- f"Target path: {target_path}\n"
317
- f"User request: {req.message}"
318
- )
319
- raw_content = await router.generate_as(
320
- router.current_model_id,
321
- message="Return only the requested file content.",
322
- context=generation_context,
323
- max_tokens=req.max_tokens,
324
- temperature=req.temperature,
325
- )
326
- content = strip_generated_file_content(str(raw_content))
327
- if content is None:
328
- raise HTTPException(status_code=400, detail="File content could not be generated.")
120
+ return current_user or claimed_email or None
121
+
122
+ def write_workspace(
123
+ requested: Optional[str],
124
+ current_user: str,
125
+ ) -> Optional[str]:
126
+ if workspace_service is None:
127
+ return requested
329
128
  try:
330
- result = execute_tool("write_file", {"path": target_path, "content": content})
331
- except ToolError as exc:
332
- raise HTTPException(status_code=400, detail=str(exc)) from exc
333
- answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
334
- created_files = [{
335
- "path": result.get("path") or target_path,
336
- "filename": Path(result.get("path") or target_path).name,
337
- "bytes": result.get("bytes", 0),
338
- "action": "write_file",
339
- }]
340
- notify_chat_message("user", req.message, req.source)
341
- notify_chat_message("assistant", answer, req.source)
342
- payload = {
343
- "status": "ok",
344
- "response": answer,
345
- "workspace": str(AGENT_ROOT),
346
- "steps": [{
347
- "state": AgentState.EXECUTING.value,
348
- "action": "write_file",
349
- "args": {"path": target_path},
350
- "result": result,
351
- }],
352
- "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
353
- "final_state": AgentState.DONE.value,
354
- "created_files": created_files,
355
- "routed_to_agent": True,
356
- "action_route": "direct_write_file",
357
- }
358
- if req.stream:
359
- return StreamingResponse(
360
- agent_payload_stream(answer, payload),
361
- media_type="text/event-stream",
362
- headers={"X-Model": router.current_model_id or "tool", "X-Routed-To": "agent"},
363
- )
364
- return JSONResponse(content=payload)
365
-
366
- async def route_file_action_to_agent(
367
- req: ChatRequest,
368
- request: Request,
369
- *,
370
- effective_email: Optional[str],
371
- ) -> JSONResponse | StreamingResponse:
372
- agent_req = AgentRequest(
373
- message=req.message,
374
- conversation_id=req.conversation_id,
375
- source=req.source or "web",
376
- max_steps=25,
377
- temperature=min(req.temperature, 0.2),
378
- user_email=effective_email,
379
- user_nickname=req.user_nickname,
380
- workspace_id=workspace_scope_from_request(request),
381
- )
382
- result = await agent(agent_req, request)
383
- answer = str(result.get("response") or "파일 작업을 처리했습니다.")
384
- notify_chat_message("user", req.message, req.source)
385
- notify_chat_message("assistant", answer, req.source)
386
- result["routed_to_agent"] = True
387
- if req.stream:
388
- return StreamingResponse(
389
- agent_payload_stream(answer, result),
390
- media_type="text/event-stream",
391
- headers={"X-Model": router.current_model_id, "X-Routed-To": "agent"},
129
+ return workspace_service.resolve_write_scope(
130
+ requested,
131
+ current_user or None,
392
132
  )
393
- return JSONResponse(content=result)
133
+ except PermissionError as exc:
134
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
394
135
 
395
136
  def history_scope_for_user(user_email: Optional[str]) -> Dict:
396
- require_auth = bool(getattr(CONFIG, "require_auth", False))
397
- scoped_user = user_email if require_auth else None
398
- allowed = None
399
- if require_auth and scoped_user and allowed_workspaces_for is not None:
400
- allowed = allowed_workspaces_for(scoped_user)
401
- return {
402
- "user_email": scoped_user,
403
- "allowed_workspaces": allowed,
404
- "include_legacy_global": not require_auth,
405
- }
137
+ return chat_service.history_scope(
138
+ user_email,
139
+ require_auth=bool(getattr(config, "require_auth", False)),
140
+ allowed_workspaces_for=allowed_workspaces_for,
141
+ )
406
142
 
407
143
  def recent_chat_context(
408
144
  limit: int = 10,
409
145
  include_image_missing_replies: bool = True,
410
146
  user_email: Optional[str] = None,
411
147
  conversation_id: Optional[str] = None,
148
+ workspace_id: Optional[str] = None,
412
149
  ) -> str:
413
150
  return build_recent_chat_context(
414
- get_history=get_history,
151
+ get_history=context.get_history,
415
152
  limit=limit,
416
153
  include_image_missing_replies=include_image_missing_replies,
417
154
  user_email=user_email,
418
155
  conversation_id=conversation_id,
156
+ workspace_id=workspace_id,
419
157
  )
420
158
 
421
- def extract_screenshot_context(image_data: Optional[str]) -> str:
422
- if not image_data:
423
- return ""
424
-
425
- lines = ["[SCREENSHOT INGESTION]"]
426
- image_bytes = b""
427
- try:
428
- image_bytes = base64.b64decode(image_data)
429
- image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
430
- lines.append(f"- image_size: {image.width}x{image.height}")
431
- lines.append(f"- image_mode: {image.mode}")
432
- except Exception as e:
433
- lines.append(f"- image_decode_error: {e}")
434
- return "\n".join(lines)
435
-
436
- tesseract_path = shutil.which("tesseract")
437
- if not tesseract_path:
438
- lines.append("- ocr: unavailable; install `tesseract` to enable OCR text extraction.")
439
- return "\n".join(lines)
440
-
441
- temp_path = None
442
- try:
443
- with tempfile.NamedTemporaryFile(prefix="ltcai-screenshot-", suffix=".png", delete=False) as temp:
444
- temp.write(image_bytes)
445
- temp_path = temp.name
446
-
447
- ocr_text = ""
448
- for lang in ("kor+eng", "eng"):
449
- completed = subprocess.run(
450
- [tesseract_path, temp_path, "stdout", "-l", lang, "--psm", "6"],
451
- capture_output=True,
452
- text=True,
453
- timeout=20,
454
- check=False,
455
- )
456
- if completed.returncode == 0 and completed.stdout.strip():
457
- ocr_text = completed.stdout.strip()
458
- lines.append(f"- ocr_language: {lang}")
459
- break
460
-
461
- if ocr_text:
462
- lines.append("- ocr_text:")
463
- lines.append(ocr_text[:4000])
464
- else:
465
- lines.append("- ocr: no text extracted.")
466
- except Exception as e:
467
- lines.append(f"- ocr_error: {e}")
468
- finally:
469
- if temp_path:
470
- try:
471
- Path(temp_path).unlink()
472
- except OSError:
473
- pass
474
-
475
- return "\n".join(lines)
476
-
477
- _AGENT_RUNTIME = context.chat_agent_runtime
478
- if _AGENT_RUNTIME is None:
479
- _AGENT_RUNTIME = build_agent_runtime(
480
- model_router=router,
159
+ agent_runtime = context.chat_agent_runtime
160
+ if agent_runtime is None:
161
+ # Keep construction here so the historical monkeypatch/import seam on
162
+ # latticeai.api.chat.build_agent_runtime remains valid.
163
+ agent_runtime = build_agent_runtime(
164
+ model_router=model_router,
481
165
  execute_tool=execute_tool,
482
166
  recent_chat_context=recent_chat_context,
483
- clear_history=clear_history,
167
+ clear_history=context.clear_history,
484
168
  knowledge_save=knowledge_save,
485
- audit=append_audit_event,
486
- hooks=hooks,
169
+ audit=context.append_audit_event,
170
+ hooks=context.hooks,
487
171
  brain_memory=context.brain_memory,
488
172
  )
489
173
 
174
+ agent_controller = AgentHTTPController(
175
+ runtime=agent_runtime,
176
+ model_router=model_router,
177
+ require_user=require_user,
178
+ require_admin=context.require_admin,
179
+ enforce_rate_limit=context.enforce_rate_limit,
180
+ authenticated_identity=authenticated_identity,
181
+ write_workspace=write_workspace,
182
+ save_to_history=context.save_to_history,
183
+ workspace_store=context.workspace_store,
184
+ workspace_graph=context.workspace_graph,
185
+ hooks=context.hooks,
186
+ execute_tool=execute_tool,
187
+ base_dir=context.base_dir or Path.cwd(),
188
+ agent_root=AGENT_ROOT,
189
+ ensure_agent_root=ensure_agent_root,
190
+ )
191
+ intent_controller = ChatIntentController(
192
+ model_router=model_router,
193
+ config=config,
194
+ public_model=context.public_model,
195
+ chat_service=chat_service,
196
+ notify=notify_chat_message,
197
+ clear_history=context.clear_history,
198
+ clear_conversation=context.clear_conversation,
199
+ history_scope_for_user=history_scope_for_user,
200
+ append_audit_event=context.append_audit_event,
201
+ enable_graph=context.enable_graph,
202
+ knowledge_graph=context.knowledge_graph,
203
+ enforce_tool_policy=enforce_tool_policy,
204
+ network_status=network_status,
205
+ tool_error=ToolError,
206
+ execute_tool=execute_tool,
207
+ agent_controller=agent_controller,
208
+ agent_root=AGENT_ROOT,
209
+ )
210
+ document_coordinator = DocumentGenerationCoordinator(
211
+ model_router=model_router,
212
+ knowledge_graph=context.knowledge_graph,
213
+ enable_graph=context.enable_graph,
214
+ chat_service=chat_service,
215
+ notify=notify_chat_message,
216
+ )
217
+
218
+ def request_model(model_id: Optional[str]) -> Optional[str]:
219
+ selected = model_id or model_router.current_model_id
220
+ if model_id and model_id not in model_router.loaded_model_ids:
221
+ raise HTTPException(
222
+ status_code=404,
223
+ detail=f"Model '{model_id}' not loaded.",
224
+ )
225
+ return selected
226
+
490
227
  @api_router.post("/chat")
491
228
  async def chat(req: ChatRequest, request: Request):
492
229
  current_user = require_user(request)
493
- enforce_rate_limit(current_user, "chat")
494
- img_len = len(req.image_data) if req.image_data else 0
230
+ context.enforce_rate_limit(current_user, "chat")
495
231
  logging.debug(
496
232
  "/chat request: stream=%s image_data_len=%s message_len=%s",
497
233
  req.stream,
498
- img_len,
234
+ len(req.image_data) if req.image_data else 0,
499
235
  len(req.message or ""),
500
236
  )
501
- effective_email = req.user_email or current_user or None
502
- history_user = get_history_user(effective_email, req.user_nickname)
237
+ effective_email = authenticated_identity(current_user, req.user_email)
238
+ workspace_id = write_workspace(
239
+ workspace_scope_from_request(request),
240
+ current_user,
241
+ )
242
+ history_user = chat_service.history_user(
243
+ effective_email,
244
+ req.user_nickname,
245
+ )
503
246
  history_meta = {
504
247
  "source": req.source or "web",
505
248
  "conversation_id": req.conversation_id,
506
- "workspace_id": workspace_scope_from_request(request),
249
+ "workspace_id": workspace_id,
507
250
  }
508
251
 
509
252
  if is_network_status_request(req.message):
510
- return await handle_network_status_intent(req, history_meta=history_meta, history_user=history_user)
511
-
253
+ return await intent_controller.network(
254
+ req,
255
+ current_user=current_user,
256
+ history_meta=history_meta,
257
+ history_user=history_user,
258
+ )
512
259
  if is_clear_command(req.message):
513
- return await handle_clear_intent(req, effective_email=effective_email)
514
-
260
+ return await intent_controller.clear(
261
+ req,
262
+ effective_email=effective_email,
263
+ workspace_id=workspace_id,
264
+ )
515
265
  if is_current_url_request(req.message) and req.client_url:
516
- return await handle_current_url_intent(req, history_meta=history_meta, history_user=history_user)
266
+ return await intent_controller.current_url(
267
+ req,
268
+ history_meta=history_meta,
269
+ history_user=history_user,
270
+ )
517
271
 
272
+ selected_model_id = request_model(req.model)
518
273
  if is_file_action_request(req.message):
519
- direct_response = await handle_direct_file_action(req)
274
+ direct_response = await intent_controller.direct_file_action(
275
+ req,
276
+ model_id=selected_model_id,
277
+ )
520
278
  if direct_response is not None:
521
279
  return direct_response
522
-
523
- if not router.current_model_id:
524
- return no_model_response()
525
-
526
- if req.model and req.model != router.current_model_id:
527
- if req.model not in router.loaded_model_ids:
528
- raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
529
- router.switch_model(req.model)
530
-
280
+ if not selected_model_id:
281
+ return intent_controller.no_model_response()
531
282
  if is_file_action_request(req.message):
532
- return await route_file_action_to_agent(req, request, effective_email=effective_email)
283
+ return await intent_controller.route_file_to_agent(
284
+ req,
285
+ request,
286
+ effective_email=effective_email,
287
+ workspace_id=workspace_id,
288
+ model_id=selected_model_id,
289
+ )
533
290
 
534
- lang = detect_language(req.message)
535
- context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
536
- # v4 Context System: one budgeted, provenance-carrying assembly
537
- # (workspace memories + hybrid search + garden notes) replaces the
538
- # ad-hoc vault-scan + LIKE-search concatenation. The trace records
539
- # why each section is in the prompt.
291
+ language = detect_language(req.message)
292
+ prompt_context = f"[LANGUAGE: {_LANG_HINT[language]}]\n" + (req.context or "")
540
293
  context_trace = None
541
294
  try:
542
- if context_assembler is not None:
543
- assembled = context_assembler.assemble(
295
+ if context.context_assembler is not None:
296
+ assembled = context.context_assembler.assemble(
544
297
  req.message,
545
298
  user_email=effective_email,
299
+ workspace_id=workspace_id,
546
300
  conversation_id=req.conversation_id,
547
301
  budget=2000,
548
302
  )
549
303
  context_trace = assembled.trace()
550
304
  if assembled.text:
551
- context += "\n\n" + assembled.text
552
- except Exception as e:
553
- logging.warning("Context assembly skipped: %s", e)
554
-
555
- is_doc_gen = detect_document_intent(req.message)
556
- doc_gen_context_result = None
557
-
558
- try:
559
- if ENABLE_GRAPH and KNOWLEDGE_GRAPH and is_doc_gen:
560
- # Specialized multi-hop retrieval for document generation.
561
- doc_gen_context_result = retrieve_context_for_generation(
562
- KNOWLEDGE_GRAPH, req.message, max_results=10, max_hops=2,
563
- )
564
- graph_md = doc_gen_context_result.get("context_markdown", "")
565
- if graph_md:
566
- context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
567
- logging.debug("Document generation context retrieved from knowledge graph.")
568
- except Exception as e:
569
- logging.warning("Knowledge graph reinforcement skipped: %s", e)
305
+ prompt_context += "\n\n" + assembled.text
306
+ except Exception as exc:
307
+ logging.warning("Context assembly skipped: %s", exc)
570
308
 
309
+ document_preparation = document_coordinator.prepare(
310
+ req,
311
+ prompt_context,
312
+ workspace_id=workspace_id,
313
+ )
314
+ prompt_context = document_preparation.context
571
315
  if req.image_data:
572
316
  screenshot_context = extract_screenshot_context(req.image_data)
573
317
  if screenshot_context:
574
- context += f"\n\n{screenshot_context}"
318
+ prompt_context += f"\n\n{screenshot_context}"
575
319
 
576
- if CONFIG.auto_read_chat_paths:
577
- _file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
578
- requested_paths = [_m.group(1).strip() for _m in _file_path_re.finditer(req.message or "")]
320
+ if getattr(config, "auto_read_chat_paths", False):
321
+ file_path_pattern = re.compile(
322
+ r'(?:^|[\s\'"(])((~|/[\w.])[^\s\'")\]]*)',
323
+ re.MULTILINE,
324
+ )
325
+ requested_paths = [
326
+ match.group(1).strip()
327
+ for match in file_path_pattern.finditer(req.message or "")
328
+ ]
579
329
  if requested_paths:
580
- append_audit_event(
330
+ context.append_audit_event(
581
331
  "auto_file_context_blocked",
582
332
  user_email=effective_email,
583
333
  path_count=len(requested_paths),
584
334
  allow_file_context=req.allow_file_context,
585
- reason="local file context requires an explicit approved file/tool flow",
335
+ reason=(
336
+ "local file context requires an explicit approved "
337
+ "file/tool flow"
338
+ ),
586
339
  )
587
340
  if req.allow_file_context:
588
341
  raise HTTPException(
589
342
  status_code=400,
590
343
  detail=(
591
344
  "Automatic local file reads are disabled in chat. "
592
- "Attach the file, upload it, or use an approved local-file tool flow."
345
+ "Attach the file, upload it, or use an approved "
346
+ "local-file tool flow."
593
347
  ),
594
348
  )
595
349
 
596
- trace_seed = CHAT_SERVICE.build_graph_trace(
350
+ trace_seed = chat_service.build_graph_trace(
597
351
  req.message,
598
- KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
599
- context,
352
+ context.knowledge_graph
353
+ if (context.enable_graph and context.knowledge_graph)
354
+ else None,
355
+ prompt_context,
356
+ allowed_workspaces={workspace_id} if workspace_id else None,
600
357
  )
601
358
  if context_trace is not None and isinstance(trace_seed, dict):
602
- # Persisted with the answer trace: 'why is this in my context?'
603
- # is answerable from the stored record (UI surface lands in T9b).
604
359
  trace_seed["context_assembly"] = context_trace
605
360
 
606
- history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
607
- await asyncio.to_thread(save_to_history, "user", history_message, **history_meta, **history_user)
361
+ history_message = (
362
+ f"{req.message}\n[Image attached]" if req.image_data else req.message
363
+ )
364
+ await chat_service.persist_entry(
365
+ "user",
366
+ history_message,
367
+ history_meta=history_meta,
368
+ history_user=history_user,
369
+ )
608
370
  notify_chat_message("user", req.message, req.source)
609
371
 
610
- if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
611
- conv_key = req.conversation_id or "default"
612
- session = _doc_gen_sessions.get(conv_key)
613
- if session is None:
614
- session = DocumentGenerationSession()
615
- _doc_gen_sessions[conv_key] = session
616
- graph_md = (doc_gen_context_result or {}).get("context_markdown", "")
617
- system_prompt = session.get_system_prompt(graph_md)
618
- sources = (doc_gen_context_result or {}).get("sources", [])
619
- footnote = format_sources_footnote(sources)
620
-
621
- if req.stream:
622
- async def _stream_doc_gen():
623
- collected = []
624
- stream_error = None
625
- try:
626
- async for chunk in router.stream_generate_document(
627
- req.message, system_prompt,
628
- max_tokens=req.max_tokens or 8192,
629
- temperature=req.temperature or 0.3,
630
- ):
631
- collected.append(chunk)
632
- yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n"
633
- except Exception as exc:
634
- stream_error = str(exc)
635
- logging.warning("document stream failed: %s", exc)
636
- yield f"data: {json.dumps({'error': stream_error}, ensure_ascii=False)}\n\n"
637
- full_text = "".join(collected)
638
- if footnote:
639
- yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
640
- full_text += footnote
641
- if stream_error:
642
- full_text = f"{full_text}\n\n[stream_error] {stream_error}" if full_text else f"[stream_error] {stream_error}"
643
- session.update(graph_md, full_text, req.conversation_id)
644
- await asyncio.to_thread(save_to_history, "assistant", full_text, **history_meta, **history_user)
645
- trace_record = CHAT_SERVICE.record_trace(
646
- question=req.message,
647
- response=full_text,
648
- conversation_id=req.conversation_id,
649
- user_email=effective_email,
650
- trace=trace_seed,
651
- )
652
- notify_chat_message("assistant", full_text, req.source)
653
- yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
654
- yield "data: [DONE]\n\n"
655
- return StreamingResponse(
656
- _stream_doc_gen(),
657
- media_type="text/event-stream",
658
- headers={"X-Model": router.current_model_id, "X-Doc-Gen": "true"},
659
- )
660
- else:
661
- result = await router.generate_document(
662
- req.message, system_prompt,
663
- max_tokens=req.max_tokens or 8192,
664
- temperature=req.temperature or 0.3,
665
- )
666
- if footnote:
667
- result += footnote
668
- session.update(graph_md, result, req.conversation_id)
669
- await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
670
- trace_record = CHAT_SERVICE.record_trace(
671
- question=req.message,
672
- response=str(result),
673
- conversation_id=req.conversation_id,
674
- user_email=effective_email,
675
- trace=trace_seed,
676
- )
677
- notify_chat_message("assistant", str(result), req.source)
678
- return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
372
+ document_response = await document_coordinator.response(
373
+ req,
374
+ document_preparation,
375
+ model_id=selected_model_id,
376
+ effective_email=effective_email,
377
+ workspace_id=workspace_id,
378
+ history_meta=history_meta,
379
+ trace_seed=trace_seed,
380
+ )
381
+ if document_response is not None:
382
+ return document_response
679
383
 
680
384
  if req.stream:
681
- recent_context = recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
682
- stream_context = context
385
+ recent_context = recent_chat_context(
386
+ user_email=effective_email,
387
+ conversation_id=req.conversation_id,
388
+ workspace_id=workspace_id,
389
+ )
390
+ stream_context = prompt_context
683
391
  if recent_context:
684
- stream_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip()
392
+ stream_context = (
393
+ f"[RECENT CONVERSATION]\n{recent_context}\n\n{prompt_context}"
394
+ ).strip()
685
395
  return StreamingResponse(
686
- _stream_chat(
396
+ stream_chat(
687
397
  req,
688
398
  stream_context,
689
399
  req.image_data,
400
+ router=model_router,
401
+ chat_service=chat_service,
402
+ knowledge_graph=context.knowledge_graph,
403
+ enable_graph=context.enable_graph,
404
+ notify=notify_chat_message,
690
405
  trace_seed=trace_seed,
691
406
  effective_email=effective_email,
692
407
  history_meta=history_meta,
408
+ model_id=selected_model_id,
409
+ workspace_id=workspace_id,
693
410
  ),
694
411
  media_type="text/event-stream",
695
- headers={"X-Model": router.current_model_id},
412
+ headers={"X-Model": selected_model_id},
696
413
  )
697
- else:
698
- if req.image_data:
699
- recent_context = recent_chat_context(
700
- limit=6,
701
- include_image_missing_replies=False,
702
- user_email=effective_email,
703
- conversation_id=req.conversation_id,
704
- )
705
- full_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip() if recent_context else context
706
- else:
707
- history_context = recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
708
- full_context = f"{history_context}\n{context}" if context else history_context
709
-
710
- result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
711
414
 
712
- await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
713
- trace_record = CHAT_SERVICE.record_trace(
714
- question=req.message,
715
- response=str(result),
415
+ if req.image_data:
416
+ recent_context = recent_chat_context(
417
+ limit=6,
418
+ include_image_missing_replies=False,
419
+ user_email=effective_email,
716
420
  conversation_id=req.conversation_id,
421
+ workspace_id=workspace_id,
422
+ )
423
+ full_context = (
424
+ f"[RECENT CONVERSATION]\n{recent_context}\n\n{prompt_context}".strip()
425
+ if recent_context
426
+ else prompt_context
427
+ )
428
+ else:
429
+ history_context = recent_chat_context(
717
430
  user_email=effective_email,
718
- trace=trace_seed,
431
+ conversation_id=req.conversation_id,
432
+ workspace_id=workspace_id,
719
433
  )
720
- notify_chat_message("assistant", str(result), req.source)
721
-
722
- return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
723
-
724
-
725
- @api_router.get("/history")
726
- async def fetch_history(request: Request):
727
- """웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
728
- current_user = require_user(request)
729
- return get_history(**history_scope_for_user(current_user))
730
-
731
- @api_router.get("/history/conversations")
732
- async def fetch_history_conversations(request: Request):
733
- """저장된 히스토리를 대화 단위로 묶어 반환합니다."""
734
- current_user = require_user(request)
735
- return group_history_conversations(get_history(**history_scope_for_user(current_user)))
736
-
737
- @api_router.get("/history/conversations/{conversation_id:path}")
738
- async def fetch_history_conversation(conversation_id: str, request: Request):
739
- """선택한 대화의 메시지를 반환합니다."""
740
- current_user = require_user(request)
741
- messages = get_conversation_messages(conversation_id, **history_scope_for_user(current_user))
742
- if not messages:
743
- raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
744
- return {"id": conversation_id, "messages": messages}
745
-
746
-
747
- @api_router.delete("/history/conversations/{conversation_id:path}")
748
- async def delete_history_conversation(conversation_id: str, request: Request):
749
- """선택한 대화방의 메시지만 삭제합니다."""
750
- email = require_user(request)
751
- result = clear_conversation(
752
- conversation_id,
753
- request.query_params.get("started_at"),
754
- **history_scope_for_user(email),
755
- )
756
- append_audit_event(
757
- "conversation_delete",
758
- user_email=email,
759
- conversation_id=conversation_id,
760
- started_at=request.query_params.get("started_at"),
761
- removed=result.get("removed", 0),
762
- kept=result.get("kept", 0),
434
+ full_context = (
435
+ f"{history_context}\n{prompt_context}"
436
+ if prompt_context
437
+ else history_context
438
+ )
439
+ result = await model_router.generate_as(
440
+ selected_model_id,
441
+ req.message,
442
+ full_context,
443
+ req.max_tokens,
444
+ req.temperature,
445
+ req.image_data,
763
446
  )
764
- return result
765
-
766
-
767
- @api_router.delete("/history")
768
- async def delete_history(request: Request, keep_last: int = 0):
769
- email = require_user(request)
770
- result = clear_history(keep_last, **history_scope_for_user(email))
771
- append_audit_event(
772
- "history_delete",
773
- user_email=email,
774
- keep_last=keep_last,
775
- removed=result.get("removed", 0),
776
- kept=result.get("kept", 0),
447
+ response_text = str(result)
448
+ trace_record = await chat_service.persist_answer(
449
+ question=req.message,
450
+ response=response_text,
451
+ conversation_id=req.conversation_id,
452
+ user_email=effective_email,
453
+ user_nickname=req.user_nickname,
454
+ source=req.source,
455
+ trace=trace_seed,
456
+ workspace_id=workspace_id,
457
+ history_meta=history_meta,
458
+ notify=notify_chat_message,
777
459
  )
778
- return result
779
-
780
- @api_router.get("/history/search")
781
- async def search_history(q: str, request: Request):
782
- """키워드로 채팅 히스토리를 검색합니다."""
783
- current_user = require_user(request)
784
- if not q or not q.strip():
785
- return {"results": [], "query": q}
786
- q_lower = q.strip().lower()
787
- history = get_history(**history_scope_for_user(current_user))
788
- matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
789
- grouped: Dict[str, Dict] = {}
790
- for item in matches:
791
- cid = item.get("conversation_id") or "legacy"
792
- if cid not in grouped:
793
- grouped[cid] = {"conversation_id": cid, "title": conversation_title(item), "messages": []}
794
- grouped[cid]["messages"].append(item)
795
- return {"results": list(grouped.values())[-30:], "query": q}
796
-
797
- async def _stream_chat(
798
- req: ChatRequest,
799
- context: str = "",
800
- image_data: str = None,
801
- *,
802
- trace_seed: Optional[Dict] = None,
803
- effective_email: Optional[str] = None,
804
- history_meta: Optional[Dict] = None,
805
- ) -> AsyncIterator[str]:
806
- full_response = ""
807
- stream_error: Optional[str] = None
808
- try:
809
- async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
810
- clean_chunk = chunk.text if hasattr(chunk, "text") else chunk
811
- full_response += str(clean_chunk)
812
- yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
813
- except Exception as exc:
814
- stream_error = str(exc)
815
- logging.warning("chat stream failed: %s", exc)
816
- yield f"data: {json.dumps({'error': stream_error, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
817
- history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
818
- persisted_response = full_response
819
- if stream_error and not persisted_response:
820
- persisted_response = f"[stream_error] {stream_error}"
821
- elif stream_error:
822
- persisted_response = f"{persisted_response}\n\n[stream_error] {stream_error}"
823
- trace_record = None
824
- try:
825
- await asyncio.to_thread(save_to_history, "assistant", persisted_response, **(history_meta or {}), **history_user)
826
- trace_record = CHAT_SERVICE.record_trace(
827
- question=req.message,
828
- response=persisted_response,
829
- conversation_id=req.conversation_id,
830
- user_email=effective_email or req.user_email,
831
- trace=trace_seed or CHAT_SERVICE.build_graph_trace(
832
- req.message,
833
- KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
834
- context,
835
- ),
836
- )
837
- notify_chat_message("assistant", persisted_response, req.source)
838
- except Exception as exc:
839
- logging.warning("chat stream persistence failed: %s", exc)
840
- trailer = {"chunk": "", "model": router.current_model_id}
841
- if trace_record:
842
- trailer.update({"trace_id": trace_record["id"], "trace": trace_record})
843
- if stream_error:
844
- trailer["error"] = stream_error
845
- yield f"data: {json.dumps(trailer, ensure_ascii=False)}\n\n"
846
- yield "data: [DONE]\n\n"
847
-
848
- @api_router.post("/agent/eval")
849
- async def agent_eval(req: AgentEvalRequest, request: Request):
850
- """Run a skill's eval cases from schema.json and return pass/fail per case."""
851
- require_user(request)
852
- skill_dir = BASE_DIR / "skills" / req.skill
853
- schema_path = skill_dir / "schema.json"
854
- if not schema_path.exists():
855
- raise HTTPException(404, detail=f"Skill '{req.skill}' not found or missing schema.json")
856
-
857
- schema = json.loads(schema_path.read_text(encoding="utf-8"))
858
- eval_cases = schema.get("evals", [])
859
- if req.case_id:
860
- eval_cases = [c for c in eval_cases if c.get("id") == req.case_id]
861
- if not eval_cases:
862
- return {"skill": req.skill, "total": 0, "passed": 0, "failed": 0, "results": [],
863
- "message": "No eval cases defined in schema.json"}
864
-
865
- action_name = schema.get("action", req.skill)
866
- results = []
867
- for case in eval_cases:
868
- case_id = case.get("id", "?")
869
- try:
870
- case_input = case.get("input", {})
871
- result = dispatch_tool(hooks, action_name, case_input,
872
- lambda: execute_tool(action_name, case_input), source="eval")
873
- criteria = case.get("pass_criteria", "")
874
- if "success == true" in criteria:
875
- passed = result.get("success") is True
876
- elif "success == false" in criteria:
877
- passed = result.get("success") is False
878
- else:
879
- passed = True # manual review required
880
- results.append({"id": case_id, "description": case.get("description", ""),
881
- "passed": passed, "result": result, "pass_criteria": criteria})
882
- except Exception as exc:
883
- results.append({"id": case_id, "description": case.get("description", ""),
884
- "passed": False, "error": str(exc),
885
- "pass_criteria": case.get("pass_criteria", "")})
886
-
887
- n_passed = sum(1 for r in results if r.get("passed") is True)
888
- return {
889
- "skill": req.skill, "action": action_name,
890
- "total": len(results), "passed": n_passed, "failed": len(results) - n_passed,
891
- "results": results,
892
- }
893
-
894
-
895
- @api_router.post("/agent")
896
- async def agent(req: AgentRequest, request: Request):
897
- """Natural-language local agent.
898
-
899
- State machine:
900
- IDLE → PLANNING → WAITING_APPROVAL → EXECUTING → VERIFYING
901
- ↓ ↓
902
- FAILED DONE | EXECUTING(retry) | ROLLBACK
903
-
904
- FAILED
905
- """
906
- current_user = require_user(request)
907
- enforce_rate_limit(current_user, "agent")
908
- req.workspace_id = req.workspace_id or workspace_scope_from_request(request)
909
- if not router.current_model_id:
910
- raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
911
-
912
- ensure_agent_root()
913
- lang = detect_language(req.message)
914
- lang_hint = _LANG_HINT[lang]
915
- max_steps = max(1, min(req.max_steps, 50))
916
- max_retry = 3
917
-
918
- ctx = AgentRunContext()
919
- ctx.executing_model = req.executing_model
920
- ctx.reviewing_model = req.reviewing_model
921
-
922
- # PLANNING phase
923
- ctx.state = AgentState.PLANNING
924
- ctx.state_history.append(ctx.state.value)
925
- await _AGENT_RUNTIME.plan(ctx, req, lang_hint, current_user, model_id=req.planning_model)
926
-
927
- # Human-in-the-loop: pause after planning, return plan to UI
928
- if req.human_in_loop:
929
- context_id = secrets.token_urlsafe(16)
930
- with _pending_agents_lock:
931
- _pending_agents[context_id] = (ctx, req, lang_hint, current_user)
932
- return {
933
- "status": "waiting_approval",
934
- "context_id": context_id,
935
- "plan": ctx.plan,
936
- "steps": ctx.transcript,
937
- "state_history": ctx.state_history,
938
- "planning_model": req.planning_model or router.current_model_id,
939
- "executing_model": req.executing_model or router.current_model_id,
940
- "reviewing_model": req.reviewing_model or router.current_model_id,
460
+ return JSONResponse(
461
+ content={
462
+ "response": response_text,
463
+ "trace_id": trace_record["id"],
464
+ "trace": trace_record,
941
465
  }
466
+ )
942
467
 
943
- # Auto-approve and run to completion (default behaviour)
944
- _AGENT_RUNTIME.approve(ctx, current_user, approved_by_human=True)
945
- return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
946
-
947
-
948
- async def _agent_finish(
949
- ctx: AgentRunContext, req: AgentRequest, lang_hint: str,
950
- current_user: str, max_steps: int, max_retry: int,
951
- ) -> dict:
952
- """HTTP glue: drive the runtime to a terminal state, persist, shape the response."""
953
- await _AGENT_RUNTIME.run_to_completion(ctx, req, lang_hint, current_user, max_steps, max_retry)
954
- schedule_background_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
955
-
956
- message = ctx.final_message or "작업을 완료했습니다."
957
- await asyncio.to_thread(save_to_history, "user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
958
- await asyncio.to_thread(save_to_history, "assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
959
- try:
960
- WORKSPACE_OS.record_agent_run(
961
- agent_id="agent:executor",
962
- status="ok" if ctx.state == AgentState.DONE else "failed",
963
- input_text=req.message,
964
- output_text=message,
965
- user_email=current_user or None,
966
- timeline=ctx.transcript,
967
- relationships=["agent:planner", "agent:reviewer"],
968
- graph=workspace_graph(),
969
- )
970
- except Exception as exc:
971
- logging.warning("workspace agent run record failed: %s", exc)
972
- created_files = collect_created_files(ctx.transcript)
973
- return {
974
- "status": "ok" if ctx.state == AgentState.DONE else "failed",
975
- "response": message,
976
- "workspace": str(AGENT_ROOT),
977
- "steps": ctx.transcript,
978
- "state_history": ctx.state_history,
979
- "final_state": ctx.state.value,
980
- "created_files": created_files,
981
- }
982
-
983
-
984
- @api_router.post("/agent/resume")
985
- async def agent_resume(req: AgentResumeRequest, request: Request):
986
- """Resume a paused agent after human approval of the plan."""
987
- current_user = require_user(request)
988
-
989
- with _pending_agents_lock:
990
- entry = _pending_agents.pop(req.context_id, None)
991
- if not entry:
992
- raise HTTPException(status_code=404, detail="Agent context not found or expired. Start a new request.")
993
-
994
- ctx, orig_req, lang_hint, _orig_user = entry
995
-
996
- if not req.approved:
997
- return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
998
-
999
- if req.modified_plan:
1000
- ctx.plan = req.modified_plan
1001
- ctx.transcript[-1].update(ctx.plan) # keep transcript in sync
1002
-
1003
- # Apply model overrides from resume request (takes priority over original request)
1004
- ctx.executing_model = req.executing_model or ctx.executing_model
1005
- ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
1006
-
1007
- _AGENT_RUNTIME.approve(ctx, current_user)
1008
-
1009
- max_steps = max(1, min(orig_req.max_steps, 50))
1010
- max_retry = 3
1011
- return await _agent_finish(ctx, orig_req, lang_hint, current_user, max_steps, max_retry)
1012
-
468
+ register_history_routes(
469
+ api_router,
470
+ HistoryRouteDependencies(
471
+ chat_service=chat_service,
472
+ require_user=require_user,
473
+ scope_for_user=history_scope_for_user,
474
+ group_conversations=context.group_history_conversations,
475
+ get_conversation_messages=context.get_conversation_messages,
476
+ conversation_title=context.conversation_title,
477
+ clear_conversation=context.clear_conversation,
478
+ clear_history=context.clear_history,
479
+ append_audit_event=context.append_audit_event,
480
+ ),
481
+ )
482
+ agent_controller.register_routes(api_router)
1013
483
  return api_router