ltcai 8.9.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -1,1080 +1,483 @@
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 AsyncIterator, Dict, List, 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
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
+ )
29
+ from latticeai.api.chat_helpers import (
30
+ _LANG_HINT,
31
+ build_recent_chat_context,
32
+ detect_language,
33
+ file_action_target,
34
+ format_network_status,
35
+ inline_file_action_content,
36
+ is_clear_command,
37
+ is_current_url_request,
38
+ is_file_action_request,
39
+ is_network_status_request,
40
+ pair_user_history,
41
+ single_text_stream,
42
+ strip_generated_file_content,
43
+ workspace_scope_from_request,
44
+ )
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
28
48
  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
-
32
- class ChatRequest(BaseModel):
33
- message: str
34
- conversation_id: Optional[str] = None
35
- client_url: Optional[str] = None
36
- model: Optional[str] = None
37
- max_tokens: int = 2048
38
- temperature: float = 0.2
39
- stream: bool = True
40
- context: Optional[str] = None
41
- source: Optional[str] = None
42
- user_email: Optional[str] = None
43
- user_nickname: Optional[str] = None
44
- image_data: Optional[str] = None
45
- allow_file_context: bool = False
46
-
47
-
48
- class AgentRequest(BaseModel):
49
- message: str
50
- conversation_id: Optional[str] = None
51
- source: Optional[str] = None
52
- max_steps: int = 25
53
- temperature: float = 0.1
54
- user_email: Optional[str] = None
55
- user_nickname: Optional[str] = None
56
- workspace_id: Optional[str] = None
57
- planning_model: Optional[str] = None
58
- executing_model: Optional[str] = None
59
- reviewing_model: Optional[str] = None
60
- human_in_loop: bool = False
61
-
62
-
63
- class AgentResumeRequest(BaseModel):
64
- context_id: str
65
- approved: bool = True
66
- modified_plan: Optional[dict] = None
67
- executing_model: Optional[str] = None
68
- reviewing_model: Optional[str] = None
69
-
70
-
71
- class AgentEvalRequest(BaseModel):
72
- skill: str
73
- case_id: Optional[str] = None
74
-
75
- def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
76
- """Restrict history to one user's exchange.
77
-
78
- Keeps the user's own messages plus assistant replies that directly follow
79
- them. A bare role=="assistant" pass would leak every other user's replies
80
- into this user's prompt context.
81
- """
82
- paired: List[Dict] = []
83
- include_next_assistant = False
84
- for item in history:
85
- if item.get("role") == "assistant":
86
- if include_next_assistant:
87
- paired.append(item)
88
- include_next_assistant = False
89
- elif item.get("user_email") == user_email:
90
- paired.append(item)
91
- include_next_assistant = True
92
- else:
93
- include_next_assistant = False
94
- return paired
95
-
96
-
97
- def detect_language(text: str) -> str:
98
- """Detect language: 'ko' (Korean) or 'en' (English)."""
99
- total = max(len(text), 1)
100
- ko = sum(1 for c in text if '가' <= c <= '힣')
101
- if ko / total > 0.05:
102
- return "ko"
103
- return "en"
104
-
105
- _LANG_HINT = {
106
- "ko": "Respond in Korean (한국어로 답변하세요).",
107
- "en": "Respond in English.",
108
- }
109
-
110
- def is_network_status_request(text: str) -> bool:
111
- """사용자가 현재 IP/네트워크 정보를 물었는지 감지합니다."""
112
- t = (text or "").lower()
113
- has_ip = bool(re.search(r"((?<![a-z0-9])ip(?![a-z0-9])|아이피|ip\s*주소|아이피\s*주소|ipconfig|ifconfig|네트워크)", t))
114
- asks_current = any(word in t for word in ["내", "현재", "지금", "local", "로컬", "주소", "address", "뭐", "알려", "확인", "상태"])
115
- return has_ip and asks_current
116
-
117
- def is_current_url_request(text: str) -> bool:
118
- t = (text or "").lower()
119
- has_url = any(word in t for word in ["url", "주소", "링크", "address"])
120
- asks_current = any(word in t for word in ["현재", "지금", "여기", "접속", "페이지", "브라우저", "알려", "뭐"])
121
- return has_url and asks_current
122
-
123
- def is_clear_command(text: str) -> bool:
124
- return (text or "").strip().lower() in {"/clear", "/clear_all"}
125
-
126
- # Path segments intentionally exclude spaces: allowing spaces let the target
127
- # match swallow preceding words (e.g. "create a text file report.txt" resolved
128
- # to the whole phrase as the path). Chat file targets are single tokens.
129
- _FILE_TARGET_RE = re.compile(
130
- r"(?<![\w.-])(?:[~./\\]?[\w.@()+-]+[\\/])*[\w.@()+-]+"
131
- r"\.(?:py|js|jsx|ts|tsx|md|markdown|txt|json|yaml|yml|toml|html|css|csv|xml|pdf|docx|xlsx|pptx|sh|sql)",
132
- re.IGNORECASE,
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,
133
58
  )
134
59
 
135
60
 
136
- def is_file_action_request(text: str) -> bool:
137
- """Return True for chat requests that should execute file tools, not prose.
138
-
139
- The Brain chat composer posts to ``/chat``. Without this gate, explicit
140
- side-effect requests such as "create hello.md" are handled as plain model
141
- generation, which commonly produces a code block instead of a real file.
142
- Keep the gate narrow so normal Q&A and "how do I create a file?" stay in
143
- ordinary chat.
144
- """
145
- raw = (text or "").strip()
146
- if not raw:
147
- return False
148
- lower = raw.lower()
149
-
150
- if any(phrase in lower for phrase in (
151
- "how to ", "how do i ", "방법", "어떻게", "예시", "sample", "example",
152
- )) and not any(phrase in lower for phrase in (
153
- "actually create", "real file", "실제로", "파일로", "저장해", "만들어",
154
- )):
155
- return False
156
-
157
- has_target = bool(_FILE_TARGET_RE.search(raw))
158
- has_file_word = any(word in lower for word in (
159
- "file", "파일", "문서", "artifact", "아티팩트", "save as", "저장",
160
- ))
161
- has_action = any(word in lower for word in (
162
- "create", "make", "write", "save", "generate", "edit", "update",
163
- "만들", "생성", "작성", "저장", "수정", "써줘", "만들어줘",
164
- ))
165
-
166
- if not has_action:
167
- return False
168
- if has_target and has_action:
169
- return True
170
- return has_file_word and has_action
171
-
172
-
173
- def file_action_target(text: str) -> Optional[str]:
174
- """Extract the first explicit workspace file target from a request."""
175
- match = _FILE_TARGET_RE.search(text or "")
176
- if not match:
177
- return None
178
- return match.group(0).strip().strip("`'\".,:;)]}")
179
-
180
-
181
- def inline_file_action_content(text: str) -> Optional[str]:
182
- """Extract short user-provided content for deterministic file writes."""
183
- raw = (text or "").strip()
184
- # Each pattern requires an explicit binder so ambiguous words like "text"
185
- # ("create a text file report.txt") or a bare "with" ("report.md with a
186
- # summary of X") do not get captured as literal file content.
187
- patterns = [
188
- r"(?:내용|본문)\s*(?:은|는|이에요|입니다)\s*(.+)$",
189
- r"(?:내용|본문|content|body|text)\s*[:=]\s*(.+)$",
190
- r"(?:content|body)\s+(?:is|as)\s+(.+)$",
191
- r"(?:with the content|with content|containing)\s+(.+)$",
192
- ]
193
- for pattern in patterns:
194
- match = re.search(pattern, raw, flags=re.IGNORECASE | re.DOTALL)
195
- if match:
196
- content = match.group(1).strip()
197
- return content.strip("`'\"")
198
- return None
199
-
200
-
201
- def strip_generated_file_content(text: str) -> str:
202
- """Remove common chat wrappers when a model is asked for file content only."""
203
- content = (text or "").strip()
204
- fenced = re.search(r"```(?:[\w.+-]+)?\s*(.*?)\s*```", content, flags=re.DOTALL)
205
- if fenced:
206
- content = fenced.group(1).strip()
207
- return content
208
-
209
-
210
- def format_network_status(info: Dict) -> str:
211
- lines = [
212
- f"내부 IP: {info.get('local_ip') or '확인 안 됨'}",
213
- f"외부 IP: {info.get('public_ip') or '확인 안 됨'}",
214
- f"호스트명: {info.get('hostname') or '확인 안 됨'}",
215
- ]
216
- local_ips = info.get("local_ips") or {}
217
- if local_ips:
218
- lines.extend(["", "인터페이스:"])
219
- lines.extend(f"- {name}: {ip}" for name, ip in local_ips.items())
220
- note = info.get("note")
221
- if note:
222
- lines.extend(["", note])
223
- return "\n".join(lines)
224
-
225
- def workspace_scope_from_request(request: Request) -> Optional[str]:
226
- header = request.headers.get("X-Workspace-Id")
227
- if header and header.strip():
228
- return header.strip()
229
- query = request.query_params.get("workspace_id")
230
- return query.strip() if query and query.strip() else None
231
-
232
- async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
233
- yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
234
- yield "data: [DONE]\n\n"
235
-
236
-
237
- def build_recent_chat_context(
238
- *,
239
- get_history,
240
- limit: int = 10,
241
- include_image_missing_replies: bool = True,
242
- user_email: Optional[str] = None,
243
- conversation_id: Optional[str] = None,
244
- ) -> str:
245
- history = get_history()
246
- if conversation_id:
247
- history = [item for item in history if item.get("conversation_id") == conversation_id]
248
- if user_email:
249
- history = pair_user_history(history, user_email)
250
- history = history[-limit:]
251
- lines = []
252
- for item in history:
253
- role = item.get("role", "user")
254
- content = item.get("content", "")
255
- if not include_image_missing_replies and role == "assistant":
256
- if "이미지" in content and any(word in content for word in ["업로드", "제공", "올려"]):
257
- continue
258
- source = item.get("source")
259
- label = role
260
- if source:
261
- label = f"{role} ({source})"
262
- lines.append(f"{label}: {content}")
263
- return "\n".join(lines)
61
+ __all__ = [
62
+ "AgentEvalRequest",
63
+ "AgentRequest",
64
+ "AgentResumeRequest",
65
+ "ChatRequest",
66
+ "create_chat_router",
67
+ "build_recent_chat_context",
68
+ "pair_user_history",
69
+ "detect_language",
70
+ "file_action_target",
71
+ "inline_file_action_content",
72
+ "is_file_action_request",
73
+ "is_network_status_request",
74
+ "is_current_url_request",
75
+ "is_clear_command",
76
+ "format_network_status",
77
+ "strip_generated_file_content",
78
+ "workspace_scope_from_request",
79
+ "single_text_stream",
80
+ ]
264
81
 
265
82
 
266
83
  def create_chat_router(context: AppContext) -> APIRouter:
267
- """Build the chat/history/agent router from the typed application context.
84
+ """Build the unchanged chat/history/agent route surface."""
268
85
 
269
- Replaces the historical ~25-kwarg factory signature: ``context``
270
- (:class:`latticeai.services.app_context.AppContext`) carries the same
271
- dependencies as typed fields.
272
- """
273
86
  api_router = APIRouter()
274
- router = context.model_router
275
- hooks = context.hooks
276
- workspace_graph = context.workspace_graph
277
- context_assembler = context.context_assembler
87
+ model_router = context.model_router
88
+ config = context.config
278
89
  require_user = context.require_user
279
- enforce_rate_limit = context.enforce_rate_limit
280
- get_history_user = context.get_history_user
281
- save_to_history = context.save_to_history
282
- append_audit_event = context.append_audit_event
283
- clear_history = context.clear_history
284
- clear_conversation = context.clear_conversation
285
- get_history = context.get_history
286
- group_history_conversations = context.group_history_conversations
287
- get_conversation_messages = context.get_conversation_messages
288
- conversation_title = context.conversation_title
90
+ workspace_service = context.workspace_service
289
91
  allowed_workspaces_for = context.allowed_workspaces_for
290
92
 
291
- CONFIG = context.config
292
- CHAT_SERVICE = context.chat_service
293
- WORKSPACE_OS = context.workspace_store
294
- ENABLE_GRAPH = context.enable_graph
295
- KNOWLEDGE_GRAPH = context.knowledge_graph
296
- PUBLIC_MODEL = context.public_model
297
- BASE_DIR = context.base_dir
298
- _doc_gen_sessions: dict = {}
299
- _pending_agents: dict[str, tuple] = {}
300
- _pending_agents_lock = threading.Lock()
301
-
302
- 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
+ )
303
100
 
304
101
  def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
305
- """Mirror a chat exchange to the injected external bridge, if any.
306
-
307
- ``on_chat_message`` is registered by ``create_app`` only when
308
- ENABLE_TELEGRAM is truthy; exchanges that originated *from* telegram
309
- are never echoed back. No bridge registered → no-op.
310
- """
311
- 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":
312
104
  return
313
105
  try:
314
- on_chat_message(role, text, source)
106
+ context.on_chat_message(role, text, source)
315
107
  except Exception as exc:
316
108
  logging.warning("chat message bridge failed: %s", exc)
317
109
 
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.",
119
+ )
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
128
+ try:
129
+ return workspace_service.resolve_write_scope(
130
+ requested,
131
+ current_user or None,
132
+ )
133
+ except PermissionError as exc:
134
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
135
+
318
136
  def history_scope_for_user(user_email: Optional[str]) -> Dict:
319
- require_auth = bool(getattr(CONFIG, "require_auth", False))
320
- scoped_user = user_email if require_auth else None
321
- allowed = None
322
- if require_auth and scoped_user and allowed_workspaces_for is not None:
323
- allowed = allowed_workspaces_for(scoped_user)
324
- return {
325
- "user_email": scoped_user,
326
- "allowed_workspaces": allowed,
327
- "include_legacy_global": not require_auth,
328
- }
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
+ )
329
142
 
330
143
  def recent_chat_context(
331
144
  limit: int = 10,
332
145
  include_image_missing_replies: bool = True,
333
146
  user_email: Optional[str] = None,
334
147
  conversation_id: Optional[str] = None,
148
+ workspace_id: Optional[str] = None,
335
149
  ) -> str:
336
150
  return build_recent_chat_context(
337
- get_history=get_history,
151
+ get_history=context.get_history,
338
152
  limit=limit,
339
153
  include_image_missing_replies=include_image_missing_replies,
340
154
  user_email=user_email,
341
155
  conversation_id=conversation_id,
156
+ workspace_id=workspace_id,
342
157
  )
343
158
 
344
- def extract_screenshot_context(image_data: Optional[str]) -> str:
345
- if not image_data:
346
- return ""
347
-
348
- lines = ["[SCREENSHOT INGESTION]"]
349
- image_bytes = b""
350
- try:
351
- image_bytes = base64.b64decode(image_data)
352
- image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
353
- lines.append(f"- image_size: {image.width}x{image.height}")
354
- lines.append(f"- image_mode: {image.mode}")
355
- except Exception as e:
356
- lines.append(f"- image_decode_error: {e}")
357
- return "\n".join(lines)
358
-
359
- tesseract_path = shutil.which("tesseract")
360
- if not tesseract_path:
361
- lines.append("- ocr: unavailable; install `tesseract` to enable OCR text extraction.")
362
- return "\n".join(lines)
363
-
364
- temp_path = None
365
- try:
366
- with tempfile.NamedTemporaryFile(prefix="ltcai-screenshot-", suffix=".png", delete=False) as temp:
367
- temp.write(image_bytes)
368
- temp_path = temp.name
369
-
370
- ocr_text = ""
371
- for lang in ("kor+eng", "eng"):
372
- completed = subprocess.run(
373
- [tesseract_path, temp_path, "stdout", "-l", lang, "--psm", "6"],
374
- capture_output=True,
375
- text=True,
376
- timeout=20,
377
- check=False,
378
- )
379
- if completed.returncode == 0 and completed.stdout.strip():
380
- ocr_text = completed.stdout.strip()
381
- lines.append(f"- ocr_language: {lang}")
382
- break
383
-
384
- if ocr_text:
385
- lines.append("- ocr_text:")
386
- lines.append(ocr_text[:4000])
387
- else:
388
- lines.append("- ocr: no text extracted.")
389
- except Exception as e:
390
- lines.append(f"- ocr_error: {e}")
391
- finally:
392
- if temp_path:
393
- try:
394
- Path(temp_path).unlink()
395
- except OSError:
396
- pass
397
-
398
- return "\n".join(lines)
399
-
400
- _AGENT_RUNTIME = context.chat_agent_runtime
401
- if _AGENT_RUNTIME is None:
402
- _AGENT_RUNTIME = build_agent_runtime(
403
- 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,
404
165
  execute_tool=execute_tool,
405
166
  recent_chat_context=recent_chat_context,
406
- clear_history=clear_history,
167
+ clear_history=context.clear_history,
407
168
  knowledge_save=knowledge_save,
408
- audit=append_audit_event,
409
- hooks=hooks,
169
+ audit=context.append_audit_event,
170
+ hooks=context.hooks,
410
171
  brain_memory=context.brain_memory,
411
172
  )
412
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
+
413
227
  @api_router.post("/chat")
414
228
  async def chat(req: ChatRequest, request: Request):
415
229
  current_user = require_user(request)
416
- enforce_rate_limit(current_user, "chat")
417
- img_len = len(req.image_data) if req.image_data else 0
418
- print(
419
- f"🧪 /chat request: stream={req.stream} image_data_len={img_len} "
420
- f"message_len={len(req.message or '')}"
230
+ context.enforce_rate_limit(current_user, "chat")
231
+ logging.debug(
232
+ "/chat request: stream=%s image_data_len=%s message_len=%s",
233
+ req.stream,
234
+ len(req.image_data) if req.image_data else 0,
235
+ len(req.message or ""),
236
+ )
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,
421
245
  )
422
- effective_email = req.user_email or current_user or None
423
- history_user = get_history_user(effective_email, req.user_nickname)
424
246
  history_meta = {
425
247
  "source": req.source or "web",
426
248
  "conversation_id": req.conversation_id,
427
- "workspace_id": workspace_scope_from_request(request),
249
+ "workspace_id": workspace_id,
428
250
  }
429
251
 
430
252
  if is_network_status_request(req.message):
431
- history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
432
- save_to_history("user", history_message, **history_meta, **history_user)
433
- try:
434
- answer = format_network_status(network_status())
435
- except ToolError as exc:
436
- answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
437
- save_to_history("assistant", answer, **history_meta, **history_user)
438
- notify_chat_message("user", req.message, req.source)
439
- notify_chat_message("assistant", answer, req.source)
440
- if req.stream:
441
- return StreamingResponse(
442
- single_text_stream(answer),
443
- media_type="text/event-stream",
444
- headers={"X-Model": "network_status"},
445
- )
446
- return JSONResponse(content={"response": answer})
447
-
253
+ return await intent_controller.network(
254
+ req,
255
+ current_user=current_user,
256
+ history_meta=history_meta,
257
+ history_user=history_user,
258
+ )
448
259
  if is_clear_command(req.message):
449
- command = req.message.strip().lower()
450
- clear_scope = "all" if command == "/clear_all" else "conversation"
451
- if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
452
- try:
453
- KNOWLEDGE_GRAPH.ingest_event(
454
- "ClearEvent",
455
- f"{command} requested",
456
- user_email=effective_email,
457
- user_nickname=req.user_nickname,
458
- source=req.source or "web",
459
- conversation_id=req.conversation_id,
460
- metadata={"command": command, "scope": clear_scope},
461
- )
462
- except Exception as e:
463
- logging.warning("knowledge graph clear event ingest failed: %s", e)
464
- if command == "/clear_all":
465
- result = clear_history(0, **history_scope_for_user(effective_email))
466
- answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
467
- else:
468
- if req.conversation_id:
469
- result = clear_conversation(req.conversation_id, **history_scope_for_user(effective_email))
470
- answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
471
- else:
472
- result = clear_history(0, **history_scope_for_user(effective_email))
473
- answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
474
- append_audit_event(
475
- "clear_command",
476
- user_email=effective_email,
477
- user_nickname=req.user_nickname,
478
- source=req.source or "web",
479
- conversation_id=req.conversation_id,
480
- command=command,
481
- scope=clear_scope,
482
- removed=result.get("removed", 0),
483
- kept=result.get("kept", 0),
260
+ return await intent_controller.clear(
261
+ req,
262
+ effective_email=effective_email,
263
+ workspace_id=workspace_id,
484
264
  )
485
- if req.stream:
486
- return StreamingResponse(
487
- single_text_stream(answer),
488
- media_type="text/event-stream",
489
- headers={"X-Model": "history"},
490
- )
491
- return JSONResponse(content={"response": answer})
492
-
493
265
  if is_current_url_request(req.message) and req.client_url:
494
- answer = f"현재 페이지 URL: {req.client_url}"
495
- save_to_history("user", req.message, **history_meta, **history_user)
496
- save_to_history("assistant", answer, **history_meta, **history_user)
497
- notify_chat_message("user", req.message, req.source)
498
- notify_chat_message("assistant", answer, req.source)
499
- if req.stream:
500
- return StreamingResponse(
501
- single_text_stream(answer),
502
- media_type="text/event-stream",
503
- headers={"X-Model": "client_url"},
504
- )
505
- return JSONResponse(content={"response": answer})
266
+ return await intent_controller.current_url(
267
+ req,
268
+ history_meta=history_meta,
269
+ history_user=history_user,
270
+ )
506
271
 
272
+ selected_model_id = request_model(req.model)
507
273
  if is_file_action_request(req.message):
508
- target_path = file_action_target(req.message)
509
- if target_path:
510
- content = inline_file_action_content(req.message)
511
- if content is None and router.current_model_id:
512
- if req.model and req.model != router.current_model_id:
513
- if req.model not in router.loaded_model_ids:
514
- raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
515
- router.switch_model(req.model)
516
- generation_context = (
517
- "Create the exact content for the requested file. "
518
- "Return only the file bytes as plain text. "
519
- "Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
520
- f"Target path: {target_path}\n"
521
- f"User request: {req.message}"
522
- )
523
- raw_content = await router.generate_as(
524
- router.current_model_id,
525
- message="Return only the requested file content.",
526
- context=generation_context,
527
- max_tokens=req.max_tokens,
528
- temperature=req.temperature,
529
- )
530
- content = strip_generated_file_content(str(raw_content))
531
- if content is None:
532
- content = ""
533
- try:
534
- result = execute_tool("write_file", {"path": target_path, "content": content})
535
- except ToolError as exc:
536
- raise HTTPException(status_code=400, detail=str(exc)) from exc
537
- answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
538
- created_files = [{
539
- "path": result.get("path") or target_path,
540
- "filename": Path(result.get("path") or target_path).name,
541
- "bytes": result.get("bytes", 0),
542
- "action": "write_file",
543
- }]
544
- notify_chat_message("user", req.message, req.source)
545
- notify_chat_message("assistant", answer, req.source)
546
- payload = {
547
- "status": "ok",
548
- "response": answer,
549
- "workspace": str(AGENT_ROOT),
550
- "steps": [{
551
- "state": AgentState.EXECUTING.value,
552
- "action": "write_file",
553
- "args": {"path": target_path},
554
- "result": result,
555
- }],
556
- "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
557
- "final_state": AgentState.DONE.value,
558
- "created_files": created_files,
559
- "routed_to_agent": True,
560
- "action_route": "direct_write_file",
561
- }
562
- if req.stream:
563
- async def _stream_file_result():
564
- yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
565
- yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
566
- yield "data: [DONE]\n\n"
567
- return StreamingResponse(
568
- _stream_file_result(),
569
- media_type="text/event-stream",
570
- headers={"X-Model": router.current_model_id or "tool", "X-Routed-To": "agent"},
571
- )
572
- return JSONResponse(content=payload)
573
-
574
- if not router.current_model_id:
575
- detail = "No model loaded. Call /models/load first."
576
- if CONFIG.is_public:
577
- 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."
578
- return JSONResponse(
579
- status_code=400,
580
- content={
581
- "error": "no_model_loaded",
582
- "detail": detail,
583
- "message": detail,
584
- "action": "load_model",
585
- },
274
+ direct_response = await intent_controller.direct_file_action(
275
+ req,
276
+ model_id=selected_model_id,
586
277
  )
587
-
588
- if req.model and req.model != router.current_model_id:
589
- if req.model not in router.loaded_model_ids:
590
- raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
591
- router.switch_model(req.model)
592
-
278
+ if direct_response is not None:
279
+ return direct_response
280
+ if not selected_model_id:
281
+ return intent_controller.no_model_response()
593
282
  if is_file_action_request(req.message):
594
- agent_req = AgentRequest(
595
- message=req.message,
596
- conversation_id=req.conversation_id,
597
- source=req.source or "web",
598
- max_steps=25,
599
- temperature=min(req.temperature, 0.2),
600
- user_email=effective_email,
601
- user_nickname=req.user_nickname,
602
- workspace_id=workspace_scope_from_request(request),
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,
603
289
  )
604
- result = await agent(agent_req, request)
605
- answer = str(result.get("response") or "파일 작업을 처리했습니다.")
606
- notify_chat_message("user", req.message, req.source)
607
- notify_chat_message("assistant", answer, req.source)
608
- result["routed_to_agent"] = True
609
- if req.stream:
610
- async def _stream_agent_result():
611
- yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
612
- yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
613
- yield "data: [DONE]\n\n"
614
- return StreamingResponse(
615
- _stream_agent_result(),
616
- media_type="text/event-stream",
617
- headers={"X-Model": router.current_model_id, "X-Routed-To": "agent"},
618
- )
619
- return JSONResponse(content=result)
620
290
 
621
- lang = detect_language(req.message)
622
- context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
623
- # v4 Context System: one budgeted, provenance-carrying assembly
624
- # (workspace memories + hybrid search + garden notes) replaces the
625
- # ad-hoc vault-scan + LIKE-search concatenation. The trace records
626
- # 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 "")
627
293
  context_trace = None
628
294
  try:
629
- if context_assembler is not None:
630
- assembled = context_assembler.assemble(
295
+ if context.context_assembler is not None:
296
+ assembled = context.context_assembler.assemble(
631
297
  req.message,
632
298
  user_email=effective_email,
299
+ workspace_id=workspace_id,
633
300
  conversation_id=req.conversation_id,
634
301
  budget=2000,
635
302
  )
636
303
  context_trace = assembled.trace()
637
304
  if assembled.text:
638
- context += "\n\n" + assembled.text
639
- except Exception as e:
640
- logging.warning("Context assembly skipped: %s", e)
641
-
642
- is_doc_gen = detect_document_intent(req.message)
643
- doc_gen_context_result = None
644
-
645
- try:
646
- if ENABLE_GRAPH and KNOWLEDGE_GRAPH and is_doc_gen:
647
- # Specialized multi-hop retrieval for document generation.
648
- doc_gen_context_result = retrieve_context_for_generation(
649
- KNOWLEDGE_GRAPH, req.message, max_results=10, max_hops=2,
650
- )
651
- graph_md = doc_gen_context_result.get("context_markdown", "")
652
- if graph_md:
653
- context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
654
- print("📝 Document generation context retrieved from knowledge graph.")
655
- except Exception as e:
656
- 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)
657
308
 
309
+ document_preparation = document_coordinator.prepare(
310
+ req,
311
+ prompt_context,
312
+ workspace_id=workspace_id,
313
+ )
314
+ prompt_context = document_preparation.context
658
315
  if req.image_data:
659
316
  screenshot_context = extract_screenshot_context(req.image_data)
660
317
  if screenshot_context:
661
- context += f"\n\n{screenshot_context}"
318
+ prompt_context += f"\n\n{screenshot_context}"
662
319
 
663
- if CONFIG.auto_read_chat_paths:
664
- _file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
665
- 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
+ ]
666
329
  if requested_paths:
667
- append_audit_event(
330
+ context.append_audit_event(
668
331
  "auto_file_context_blocked",
669
332
  user_email=effective_email,
670
333
  path_count=len(requested_paths),
671
334
  allow_file_context=req.allow_file_context,
672
- 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
+ ),
673
339
  )
674
340
  if req.allow_file_context:
675
341
  raise HTTPException(
676
342
  status_code=400,
677
343
  detail=(
678
344
  "Automatic local file reads are disabled in chat. "
679
- "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."
680
347
  ),
681
348
  )
682
349
 
683
- trace_seed = CHAT_SERVICE.build_graph_trace(
350
+ trace_seed = chat_service.build_graph_trace(
684
351
  req.message,
685
- KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
686
- 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,
687
357
  )
688
358
  if context_trace is not None and isinstance(trace_seed, dict):
689
- # Persisted with the answer trace: 'why is this in my context?'
690
- # is answerable from the stored record (UI surface lands in T9b).
691
359
  trace_seed["context_assembly"] = context_trace
692
360
 
693
- history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
694
- 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
+ )
695
370
  notify_chat_message("user", req.message, req.source)
696
371
 
697
- if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
698
- conv_key = req.conversation_id or "default"
699
- session = _doc_gen_sessions.get(conv_key)
700
- if session is None:
701
- session = DocumentGenerationSession()
702
- _doc_gen_sessions[conv_key] = session
703
- graph_md = (doc_gen_context_result or {}).get("context_markdown", "")
704
- system_prompt = session.get_system_prompt(graph_md)
705
- sources = (doc_gen_context_result or {}).get("sources", [])
706
- footnote = format_sources_footnote(sources)
707
-
708
- if req.stream:
709
- async def _stream_doc_gen():
710
- collected = []
711
- async for chunk in router.stream_generate_document(
712
- req.message, system_prompt,
713
- max_tokens=req.max_tokens or 8192,
714
- temperature=req.temperature or 0.3,
715
- ):
716
- collected.append(chunk)
717
- yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n"
718
- full_text = "".join(collected)
719
- if footnote:
720
- yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
721
- full_text += footnote
722
- session.update(graph_md, full_text, req.conversation_id)
723
- save_to_history("assistant", full_text, **history_meta, **history_user)
724
- trace_record = CHAT_SERVICE.record_trace(
725
- question=req.message,
726
- response=full_text,
727
- conversation_id=req.conversation_id,
728
- user_email=effective_email,
729
- trace=trace_seed,
730
- )
731
- notify_chat_message("assistant", full_text, req.source)
732
- yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
733
- yield "data: [DONE]\n\n"
734
- return StreamingResponse(
735
- _stream_doc_gen(),
736
- media_type="text/event-stream",
737
- headers={"X-Model": router.current_model_id, "X-Doc-Gen": "true"},
738
- )
739
- else:
740
- result = await router.generate_document(
741
- req.message, system_prompt,
742
- max_tokens=req.max_tokens or 8192,
743
- temperature=req.temperature or 0.3,
744
- )
745
- if footnote:
746
- result += footnote
747
- session.update(graph_md, result, req.conversation_id)
748
- save_to_history("assistant", str(result), **history_meta, **history_user)
749
- trace_record = CHAT_SERVICE.record_trace(
750
- question=req.message,
751
- response=str(result),
752
- conversation_id=req.conversation_id,
753
- user_email=effective_email,
754
- trace=trace_seed,
755
- )
756
- notify_chat_message("assistant", str(result), req.source)
757
- 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
758
383
 
759
384
  if req.stream:
760
- recent_context = recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
761
- 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
762
391
  if recent_context:
763
- 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()
764
395
  return StreamingResponse(
765
- _stream_chat(
396
+ stream_chat(
766
397
  req,
767
398
  stream_context,
768
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,
769
405
  trace_seed=trace_seed,
770
406
  effective_email=effective_email,
771
407
  history_meta=history_meta,
408
+ model_id=selected_model_id,
409
+ workspace_id=workspace_id,
772
410
  ),
773
411
  media_type="text/event-stream",
774
- headers={"X-Model": router.current_model_id},
412
+ headers={"X-Model": selected_model_id},
775
413
  )
776
- else:
777
- if req.image_data:
778
- recent_context = recent_chat_context(
779
- limit=6,
780
- include_image_missing_replies=False,
781
- user_email=effective_email,
782
- conversation_id=req.conversation_id,
783
- )
784
- full_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip() if recent_context else context
785
- else:
786
- history_context = recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
787
- full_context = f"{history_context}\n{context}" if context else history_context
788
-
789
- result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
790
414
 
791
- save_to_history("assistant", str(result), **history_meta, **history_user)
792
- trace_record = CHAT_SERVICE.record_trace(
793
- question=req.message,
794
- 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,
795
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(
796
430
  user_email=effective_email,
797
- trace=trace_seed,
431
+ conversation_id=req.conversation_id,
432
+ workspace_id=workspace_id,
798
433
  )
799
- notify_chat_message("assistant", str(result), req.source)
800
-
801
- return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
802
-
803
-
804
- @api_router.get("/history")
805
- async def fetch_history(request: Request):
806
- """웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
807
- current_user = require_user(request)
808
- return get_history(**history_scope_for_user(current_user))
809
-
810
- @api_router.get("/history/conversations")
811
- async def fetch_history_conversations(request: Request):
812
- """저장된 히스토리를 대화 단위로 묶어 반환합니다."""
813
- current_user = require_user(request)
814
- return group_history_conversations(get_history(**history_scope_for_user(current_user)))
815
-
816
- @api_router.get("/history/conversations/{conversation_id:path}")
817
- async def fetch_history_conversation(conversation_id: str, request: Request):
818
- """선택한 대화의 메시지를 반환합니다."""
819
- current_user = require_user(request)
820
- messages = get_conversation_messages(conversation_id, **history_scope_for_user(current_user))
821
- if not messages:
822
- raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
823
- return {"id": conversation_id, "messages": messages}
824
-
825
-
826
- @api_router.delete("/history/conversations/{conversation_id:path}")
827
- async def delete_history_conversation(conversation_id: str, request: Request):
828
- """선택한 대화방의 메시지만 삭제합니다."""
829
- email = require_user(request)
830
- result = clear_conversation(
831
- conversation_id,
832
- request.query_params.get("started_at"),
833
- **history_scope_for_user(email),
834
- )
835
- append_audit_event(
836
- "conversation_delete",
837
- user_email=email,
838
- conversation_id=conversation_id,
839
- started_at=request.query_params.get("started_at"),
840
- removed=result.get("removed", 0),
841
- kept=result.get("kept", 0),
842
- )
843
- return result
844
-
845
-
846
- @api_router.delete("/history")
847
- async def delete_history(request: Request, keep_last: int = 0):
848
- email = require_user(request)
849
- result = clear_history(keep_last, **history_scope_for_user(email))
850
- append_audit_event(
851
- "history_delete",
852
- user_email=email,
853
- keep_last=keep_last,
854
- removed=result.get("removed", 0),
855
- 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,
856
446
  )
857
- return result
858
-
859
- @api_router.get("/history/search")
860
- async def search_history(q: str, request: Request):
861
- """키워드로 채팅 히스토리를 검색합니다."""
862
- current_user = require_user(request)
863
- if not q or not q.strip():
864
- return {"results": [], "query": q}
865
- q_lower = q.strip().lower()
866
- history = get_history(**history_scope_for_user(current_user))
867
- matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
868
- grouped: Dict[str, Dict] = {}
869
- for item in matches:
870
- cid = item.get("conversation_id") or "legacy"
871
- if cid not in grouped:
872
- grouped[cid] = {"conversation_id": cid, "title": conversation_title(item), "messages": []}
873
- grouped[cid]["messages"].append(item)
874
- return {"results": list(grouped.values())[-30:], "query": q}
875
-
876
- async def _stream_chat(
877
- req: ChatRequest,
878
- context: str = "",
879
- image_data: str = None,
880
- *,
881
- trace_seed: Optional[Dict] = None,
882
- effective_email: Optional[str] = None,
883
- history_meta: Optional[Dict] = None,
884
- ) -> AsyncIterator[str]:
885
- full_response = ""
886
- async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
887
- clean_chunk = chunk
888
- if hasattr(chunk, "text"):
889
- clean_chunk = chunk.text
890
- elif isinstance(chunk, str) and "text='" in chunk:
891
- try:
892
- clean_chunk = chunk.split("text='")[1].split("', token=")[0].replace('\\n', '\n').replace('\\\\n', '\n')
893
- except Exception:
894
- pass
895
-
896
- full_response += str(clean_chunk)
897
- yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
898
- history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
899
- save_to_history("assistant", full_response, **(history_meta or {}), **history_user)
900
- trace_record = CHAT_SERVICE.record_trace(
447
+ response_text = str(result)
448
+ trace_record = await chat_service.persist_answer(
901
449
  question=req.message,
902
- response=full_response,
450
+ response=response_text,
903
451
  conversation_id=req.conversation_id,
904
- user_email=effective_email or req.user_email,
905
- trace=trace_seed or CHAT_SERVICE.build_graph_trace(
906
- req.message,
907
- KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
908
- context,
909
- ),
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,
910
459
  )
911
- notify_chat_message("assistant", full_response, req.source)
912
- yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
913
- yield "data: [DONE]\n\n"
914
-
915
- @api_router.post("/agent/eval")
916
- async def agent_eval(req: AgentEvalRequest, request: Request):
917
- """Run a skill's eval cases from schema.json and return pass/fail per case."""
918
- require_user(request)
919
- skill_dir = BASE_DIR / "skills" / req.skill
920
- schema_path = skill_dir / "schema.json"
921
- if not schema_path.exists():
922
- raise HTTPException(404, detail=f"Skill '{req.skill}' not found or missing schema.json")
923
-
924
- schema = json.loads(schema_path.read_text(encoding="utf-8"))
925
- eval_cases = schema.get("evals", [])
926
- if req.case_id:
927
- eval_cases = [c for c in eval_cases if c.get("id") == req.case_id]
928
- if not eval_cases:
929
- return {"skill": req.skill, "total": 0, "passed": 0, "failed": 0, "results": [],
930
- "message": "No eval cases defined in schema.json"}
931
-
932
- action_name = schema.get("action", req.skill)
933
- results = []
934
- for case in eval_cases:
935
- case_id = case.get("id", "?")
936
- try:
937
- case_input = case.get("input", {})
938
- result = dispatch_tool(hooks, action_name, case_input,
939
- lambda: execute_tool(action_name, case_input), source="eval")
940
- criteria = case.get("pass_criteria", "")
941
- if "success == true" in criteria:
942
- passed = result.get("success") is True
943
- elif "success == false" in criteria:
944
- passed = result.get("success") is False
945
- else:
946
- passed = True # manual review required
947
- results.append({"id": case_id, "description": case.get("description", ""),
948
- "passed": passed, "result": result, "pass_criteria": criteria})
949
- except Exception as exc:
950
- results.append({"id": case_id, "description": case.get("description", ""),
951
- "passed": False, "error": str(exc),
952
- "pass_criteria": case.get("pass_criteria", "")})
953
-
954
- n_passed = sum(1 for r in results if r.get("passed") is True)
955
- return {
956
- "skill": req.skill, "action": action_name,
957
- "total": len(results), "passed": n_passed, "failed": len(results) - n_passed,
958
- "results": results,
959
- }
960
-
961
-
962
- @api_router.post("/agent")
963
- async def agent(req: AgentRequest, request: Request):
964
- """Natural-language local agent.
965
-
966
- State machine:
967
- IDLE → PLANNING → WAITING_APPROVAL → EXECUTING → VERIFYING
968
- ↓ ↓
969
- FAILED DONE | EXECUTING(retry) | ROLLBACK
970
-
971
- FAILED
972
- """
973
- current_user = require_user(request)
974
- enforce_rate_limit(current_user, "agent")
975
- req.workspace_id = req.workspace_id or workspace_scope_from_request(request)
976
- if not router.current_model_id:
977
- raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
978
-
979
- ensure_agent_root()
980
- lang = detect_language(req.message)
981
- lang_hint = _LANG_HINT[lang]
982
- max_steps = max(1, min(req.max_steps, 50))
983
- max_retry = 3
984
-
985
- ctx = AgentRunContext()
986
- ctx.executing_model = req.executing_model
987
- ctx.reviewing_model = req.reviewing_model
988
-
989
- # PLANNING phase
990
- ctx.state = AgentState.PLANNING
991
- ctx.state_history.append(ctx.state.value)
992
- await _AGENT_RUNTIME.plan(ctx, req, lang_hint, current_user, model_id=req.planning_model)
993
-
994
- # Human-in-the-loop: pause after planning, return plan to UI
995
- if req.human_in_loop:
996
- context_id = secrets.token_urlsafe(16)
997
- with _pending_agents_lock:
998
- _pending_agents[context_id] = (ctx, req, lang_hint, current_user)
999
- return {
1000
- "status": "waiting_approval",
1001
- "context_id": context_id,
1002
- "plan": ctx.plan,
1003
- "steps": ctx.transcript,
1004
- "state_history": ctx.state_history,
1005
- "planning_model": req.planning_model or router.current_model_id,
1006
- "executing_model": req.executing_model or router.current_model_id,
1007
- "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,
1008
465
  }
466
+ )
1009
467
 
1010
- # Auto-approve and run to completion (default behaviour)
1011
- _AGENT_RUNTIME.approve(ctx, current_user, approved_by_human=True)
1012
- return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
1013
-
1014
-
1015
- async def _agent_finish(
1016
- ctx: AgentRunContext, req: AgentRequest, lang_hint: str,
1017
- current_user: str, max_steps: int, max_retry: int,
1018
- ) -> dict:
1019
- """HTTP glue: drive the runtime to a terminal state, persist, shape the response."""
1020
- await _AGENT_RUNTIME.run_to_completion(ctx, req, lang_hint, current_user, max_steps, max_retry)
1021
- asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
1022
-
1023
- message = ctx.final_message or "작업을 완료했습니다."
1024
- save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
1025
- save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
1026
- try:
1027
- WORKSPACE_OS.record_agent_run(
1028
- agent_id="agent:executor",
1029
- status="ok" if ctx.state == AgentState.DONE else "failed",
1030
- input_text=req.message,
1031
- output_text=message,
1032
- user_email=current_user or None,
1033
- timeline=ctx.transcript,
1034
- relationships=["agent:planner", "agent:reviewer"],
1035
- graph=workspace_graph(),
1036
- )
1037
- except Exception as exc:
1038
- logging.warning("workspace agent run record failed: %s", exc)
1039
- created_files = collect_created_files(ctx.transcript)
1040
- return {
1041
- "status": "ok" if ctx.state == AgentState.DONE else "failed",
1042
- "response": message,
1043
- "workspace": str(AGENT_ROOT),
1044
- "steps": ctx.transcript,
1045
- "state_history": ctx.state_history,
1046
- "final_state": ctx.state.value,
1047
- "created_files": created_files,
1048
- }
1049
-
1050
-
1051
- @api_router.post("/agent/resume")
1052
- async def agent_resume(req: AgentResumeRequest, request: Request):
1053
- """Resume a paused agent after human approval of the plan."""
1054
- current_user = require_user(request)
1055
-
1056
- with _pending_agents_lock:
1057
- entry = _pending_agents.pop(req.context_id, None)
1058
- if not entry:
1059
- raise HTTPException(status_code=404, detail="Agent context not found or expired. Start a new request.")
1060
-
1061
- ctx, orig_req, lang_hint, _orig_user = entry
1062
-
1063
- if not req.approved:
1064
- return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
1065
-
1066
- if req.modified_plan:
1067
- ctx.plan = req.modified_plan
1068
- ctx.transcript[-1].update(ctx.plan) # keep transcript in sync
1069
-
1070
- # Apply model overrides from resume request (takes priority over original request)
1071
- ctx.executing_model = req.executing_model or ctx.executing_model
1072
- ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
1073
-
1074
- _AGENT_RUNTIME.approve(ctx, current_user)
1075
-
1076
- max_steps = max(1, min(orig_req.max_steps, 50))
1077
- max_retry = 3
1078
- return await _agent_finish(ctx, orig_req, lang_hint, current_user, max_steps, max_retry)
1079
-
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)
1080
483
  return api_router