ltcai 8.8.0 → 9.0.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 (111) hide show
  1. package/README.md +33 -37
  2. package/auto_setup.py +84 -70
  3. package/docs/CHANGELOG.md +113 -237
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  11. package/docs/TRUST_MODEL.md +5 -1
  12. package/docs/WHY_LATTICE.md +4 -3
  13. package/docs/architecture.md +4 -0
  14. package/docs/kg-schema.md +1 -1
  15. package/lattice_brain/__init__.py +1 -1
  16. package/lattice_brain/archive.py +4 -9
  17. package/lattice_brain/conversations.py +156 -21
  18. package/lattice_brain/embeddings.py +38 -2
  19. package/lattice_brain/graph/_kg_common.py +39 -496
  20. package/lattice_brain/graph/_kg_constants.py +243 -0
  21. package/lattice_brain/graph/_kg_fsutil.py +297 -0
  22. package/lattice_brain/graph/discovery.py +0 -948
  23. package/lattice_brain/graph/discovery_index.py +972 -0
  24. package/lattice_brain/graph/json_utils.py +25 -0
  25. package/lattice_brain/graph/retrieval.py +66 -597
  26. package/lattice_brain/graph/retrieval_docgen.py +210 -0
  27. package/lattice_brain/graph/retrieval_vector.py +460 -0
  28. package/lattice_brain/graph/runtime.py +16 -0
  29. package/lattice_brain/graph/store.py +6 -0
  30. package/lattice_brain/ingestion.py +68 -0
  31. package/lattice_brain/portability.py +1 -9
  32. package/lattice_brain/quality.py +98 -4
  33. package/lattice_brain/runtime/agent_runtime.py +166 -0
  34. package/lattice_brain/runtime/multi_agent.py +1 -1
  35. package/lattice_brain/utils.py +28 -0
  36. package/latticeai/__init__.py +1 -1
  37. package/latticeai/api/chat.py +368 -418
  38. package/latticeai/api/chat_helpers.py +227 -0
  39. package/latticeai/api/computer_use.py +149 -31
  40. package/latticeai/api/marketplace.py +11 -0
  41. package/latticeai/api/mcp.py +3 -2
  42. package/latticeai/api/models.py +4 -1
  43. package/latticeai/api/permissions.py +72 -33
  44. package/latticeai/api/setup.py +17 -2
  45. package/latticeai/api/tools.py +105 -62
  46. package/latticeai/app_factory.py +101 -296
  47. package/latticeai/core/agent.py +25 -7
  48. package/latticeai/core/io_utils.py +37 -0
  49. package/latticeai/core/legacy_compatibility.py +1 -1
  50. package/latticeai/core/local_embeddings.py +2 -4
  51. package/latticeai/core/marketplace.py +33 -2
  52. package/latticeai/core/mcp_catalog.py +450 -0
  53. package/latticeai/core/mcp_registry.py +2 -441
  54. package/latticeai/core/sessions.py +11 -3
  55. package/latticeai/core/tool_registry.py +15 -4
  56. package/latticeai/core/users.py +4 -9
  57. package/latticeai/core/workspace_os.py +1 -1
  58. package/latticeai/core/workspace_os_utils.py +3 -17
  59. package/latticeai/integrations/telegram_bot.py +7 -2
  60. package/latticeai/models/model_providers.py +111 -0
  61. package/latticeai/models/router.py +58 -136
  62. package/latticeai/runtime/audit_runtime.py +27 -16
  63. package/latticeai/runtime/automation_runtime.py +9 -0
  64. package/latticeai/runtime/bootstrap.py +1 -1
  65. package/latticeai/runtime/history_runtime.py +163 -0
  66. package/latticeai/runtime/namespace_runtime.py +173 -0
  67. package/latticeai/runtime/network_config_runtime.py +56 -0
  68. package/latticeai/runtime/sso_config_runtime.py +128 -0
  69. package/latticeai/runtime/user_key_runtime.py +106 -0
  70. package/latticeai/services/app_context.py +1 -0
  71. package/latticeai/services/architecture_readiness.py +2 -2
  72. package/latticeai/services/memory_service.py +213 -0
  73. package/latticeai/services/model_engines.py +79 -12
  74. package/latticeai/services/model_runtime.py +24 -4
  75. package/latticeai/services/platform_runtime.py +9 -1
  76. package/latticeai/services/process_audit.py +208 -0
  77. package/latticeai/services/product_readiness.py +11 -11
  78. package/latticeai/services/review_queue.py +64 -11
  79. package/latticeai/services/run_executor.py +21 -0
  80. package/latticeai/services/search_service.py +106 -30
  81. package/latticeai/services/setup_detection.py +80 -0
  82. package/latticeai/services/tool_dispatch.py +66 -0
  83. package/latticeai/services/workspace_service.py +15 -0
  84. package/package.json +1 -1
  85. package/scripts/check_i18n_literals.mjs +20 -8
  86. package/scripts/i18n_literal_allowlist.json +34 -0
  87. package/scripts/lint_frontend.mjs +6 -2
  88. package/setup_wizard.py +196 -74
  89. package/src-tauri/Cargo.lock +1 -1
  90. package/src-tauri/Cargo.toml +1 -1
  91. package/src-tauri/tauri.conf.json +1 -1
  92. package/static/app/asset-manifest.json +11 -11
  93. package/static/app/assets/{Act-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
  94. package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
  95. package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
  96. package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
  97. package/static/app/assets/System-K6krGCqn.js +1 -0
  98. package/static/app/assets/index-C4R3ws30.js +17 -0
  99. package/static/app/assets/index-ChSeOB02.css +2 -0
  100. package/static/app/assets/primitives-sQU3it5I.js +1 -0
  101. package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
  102. package/static/app/index.html +2 -2
  103. package/static/css/tokens.css +4 -2
  104. package/static/sw.js +1 -1
  105. package/tools/local_files.py +6 -0
  106. package/latticeai/runtime/sso_runtime.py +0 -52
  107. package/static/app/assets/Brain-I1OSzxJu.js +0 -321
  108. package/static/app/assets/System-D1Lkei3I.js +0 -1
  109. package/static/app/assets/index--P0ksosz.js +0 -17
  110. package/static/app/assets/index-DCh5AoXt.css +0 -2
  111. package/static/app/assets/primitives-BLqaKk5g.js +0 -1
@@ -14,7 +14,7 @@ import subprocess
14
14
  import tempfile
15
15
  import threading
16
16
  from pathlib import Path
17
- from typing import AsyncIterator, Dict, List, Optional
17
+ from typing import Any, AsyncIterator, Dict, Optional
18
18
 
19
19
  from fastapi import APIRouter, HTTPException, Request
20
20
  from fastapi.responses import JSONResponse, StreamingResponse
@@ -28,6 +28,25 @@ from lattice_brain.runtime.hooks import dispatch_tool
28
28
  from latticeai.services.app_context import AppContext
29
29
  from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
30
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.
34
+ from latticeai.api.chat_helpers import (
35
+ _LANG_HINT,
36
+ build_recent_chat_context,
37
+ detect_language,
38
+ file_action_target,
39
+ format_network_status,
40
+ inline_file_action_content,
41
+ is_clear_command,
42
+ is_current_url_request,
43
+ is_file_action_request,
44
+ is_network_status_request,
45
+ pair_user_history,
46
+ single_text_stream,
47
+ strip_generated_file_content,
48
+ workspace_scope_from_request,
49
+ )
31
50
 
32
51
  class ChatRequest(BaseModel):
33
52
  message: str
@@ -72,195 +91,26 @@ class AgentEvalRequest(BaseModel):
72
91
  skill: str
73
92
  case_id: Optional[str] = None
74
93
 
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,
133
- )
134
94
 
135
-
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)
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
+ __all__ = [
99
+ "create_chat_router",
100
+ "build_recent_chat_context",
101
+ "pair_user_history",
102
+ "detect_language",
103
+ "file_action_target",
104
+ "inline_file_action_content",
105
+ "is_file_action_request",
106
+ "is_network_status_request",
107
+ "is_current_url_request",
108
+ "is_clear_command",
109
+ "format_network_status",
110
+ "strip_generated_file_content",
111
+ "workspace_scope_from_request",
112
+ "single_text_stream",
113
+ ]
264
114
 
265
115
 
266
116
  def create_chat_router(context: AppContext) -> APIRouter:
@@ -286,6 +136,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
286
136
  group_history_conversations = context.group_history_conversations
287
137
  get_conversation_messages = context.get_conversation_messages
288
138
  conversation_title = context.conversation_title
139
+ allowed_workspaces_for = context.allowed_workspaces_for
289
140
 
290
141
  CONFIG = context.config
291
142
  CHAT_SERVICE = context.chat_service
@@ -297,6 +148,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
297
148
  _doc_gen_sessions: dict = {}
298
149
  _pending_agents: dict[str, tuple] = {}
299
150
  _pending_agents_lock = threading.Lock()
151
+ _background_tasks: set[asyncio.Task] = set()
300
152
 
301
153
  on_chat_message = context.on_chat_message
302
154
 
@@ -314,6 +166,244 @@ def create_chat_router(context: AppContext) -> APIRouter:
314
166
  except Exception as exc:
315
167
  logging.warning("chat message bridge failed: %s", exc)
316
168
 
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},
263
+ )
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.")
329
+ 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"},
392
+ )
393
+ return JSONResponse(content=result)
394
+
395
+ 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
+ }
406
+
317
407
  def recent_chat_context(
318
408
  limit: int = 10,
319
409
  include_image_missing_replies: bool = True,
@@ -402,9 +492,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
402
492
  current_user = require_user(request)
403
493
  enforce_rate_limit(current_user, "chat")
404
494
  img_len = len(req.image_data) if req.image_data else 0
405
- print(
406
- f"🧪 /chat request: stream={req.stream} image_data_len={img_len} "
407
- f"message_len={len(req.message or '')}"
495
+ logging.debug(
496
+ "/chat request: stream=%s image_data_len=%s message_len=%s",
497
+ req.stream,
498
+ img_len,
499
+ len(req.message or ""),
408
500
  )
409
501
  effective_email = req.user_email or current_user or None
410
502
  history_user = get_history_user(effective_email, req.user_nickname)
@@ -415,162 +507,21 @@ def create_chat_router(context: AppContext) -> APIRouter:
415
507
  }
416
508
 
417
509
  if is_network_status_request(req.message):
418
- history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
419
- save_to_history("user", history_message, **history_meta, **history_user)
420
- try:
421
- answer = format_network_status(network_status())
422
- except ToolError as exc:
423
- answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
424
- save_to_history("assistant", answer, **history_meta, **history_user)
425
- notify_chat_message("user", req.message, req.source)
426
- notify_chat_message("assistant", answer, req.source)
427
- if req.stream:
428
- return StreamingResponse(
429
- single_text_stream(answer),
430
- media_type="text/event-stream",
431
- headers={"X-Model": "network_status"},
432
- )
433
- return JSONResponse(content={"response": answer})
510
+ return await handle_network_status_intent(req, history_meta=history_meta, history_user=history_user)
434
511
 
435
512
  if is_clear_command(req.message):
436
- command = req.message.strip().lower()
437
- clear_scope = "all" if command == "/clear_all" else "conversation"
438
- if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
439
- try:
440
- KNOWLEDGE_GRAPH.ingest_event(
441
- "ClearEvent",
442
- f"{command} requested",
443
- user_email=effective_email,
444
- user_nickname=req.user_nickname,
445
- source=req.source or "web",
446
- conversation_id=req.conversation_id,
447
- metadata={"command": command, "scope": clear_scope},
448
- )
449
- except Exception as e:
450
- logging.warning("knowledge graph clear event ingest failed: %s", e)
451
- if command == "/clear_all":
452
- result = clear_history(0)
453
- answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
454
- else:
455
- if req.conversation_id:
456
- result = clear_conversation(req.conversation_id)
457
- answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
458
- else:
459
- result = clear_history(0)
460
- answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
461
- append_audit_event(
462
- "clear_command",
463
- user_email=effective_email,
464
- user_nickname=req.user_nickname,
465
- source=req.source or "web",
466
- conversation_id=req.conversation_id,
467
- command=command,
468
- scope=clear_scope,
469
- removed=result.get("removed", 0),
470
- kept=result.get("kept", 0),
471
- )
472
- if req.stream:
473
- return StreamingResponse(
474
- single_text_stream(answer),
475
- media_type="text/event-stream",
476
- headers={"X-Model": "history"},
477
- )
478
- return JSONResponse(content={"response": answer})
513
+ return await handle_clear_intent(req, effective_email=effective_email)
479
514
 
480
515
  if is_current_url_request(req.message) and req.client_url:
481
- answer = f"현재 페이지 URL: {req.client_url}"
482
- save_to_history("user", req.message, **history_meta, **history_user)
483
- save_to_history("assistant", answer, **history_meta, **history_user)
484
- notify_chat_message("user", req.message, req.source)
485
- notify_chat_message("assistant", answer, req.source)
486
- if req.stream:
487
- return StreamingResponse(
488
- single_text_stream(answer),
489
- media_type="text/event-stream",
490
- headers={"X-Model": "client_url"},
491
- )
492
- return JSONResponse(content={"response": answer})
516
+ return await handle_current_url_intent(req, history_meta=history_meta, history_user=history_user)
493
517
 
494
518
  if is_file_action_request(req.message):
495
- target_path = file_action_target(req.message)
496
- if target_path:
497
- content = inline_file_action_content(req.message)
498
- if content is None and router.current_model_id:
499
- if req.model and req.model != router.current_model_id:
500
- if req.model not in router.loaded_model_ids:
501
- raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
502
- router.switch_model(req.model)
503
- generation_context = (
504
- "Create the exact content for the requested file. "
505
- "Return only the file bytes as plain text. "
506
- "Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
507
- f"Target path: {target_path}\n"
508
- f"User request: {req.message}"
509
- )
510
- raw_content = await router.generate_as(
511
- router.current_model_id,
512
- message="Return only the requested file content.",
513
- context=generation_context,
514
- max_tokens=req.max_tokens,
515
- temperature=req.temperature,
516
- )
517
- content = strip_generated_file_content(str(raw_content))
518
- if content is None:
519
- content = ""
520
- try:
521
- result = execute_tool("write_file", {"path": target_path, "content": content})
522
- except ToolError as exc:
523
- raise HTTPException(status_code=400, detail=str(exc)) from exc
524
- answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
525
- created_files = [{
526
- "path": result.get("path") or target_path,
527
- "filename": Path(result.get("path") or target_path).name,
528
- "bytes": result.get("bytes", 0),
529
- "action": "write_file",
530
- }]
531
- notify_chat_message("user", req.message, req.source)
532
- notify_chat_message("assistant", answer, req.source)
533
- payload = {
534
- "status": "ok",
535
- "response": answer,
536
- "workspace": str(AGENT_ROOT),
537
- "steps": [{
538
- "state": AgentState.EXECUTING.value,
539
- "action": "write_file",
540
- "args": {"path": target_path},
541
- "result": result,
542
- }],
543
- "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
544
- "final_state": AgentState.DONE.value,
545
- "created_files": created_files,
546
- "routed_to_agent": True,
547
- "action_route": "direct_write_file",
548
- }
549
- if req.stream:
550
- async def _stream_file_result():
551
- yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
552
- yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
553
- yield "data: [DONE]\n\n"
554
- return StreamingResponse(
555
- _stream_file_result(),
556
- media_type="text/event-stream",
557
- headers={"X-Model": router.current_model_id or "tool", "X-Routed-To": "agent"},
558
- )
559
- return JSONResponse(content=payload)
519
+ direct_response = await handle_direct_file_action(req)
520
+ if direct_response is not None:
521
+ return direct_response
560
522
 
561
523
  if not router.current_model_id:
562
- detail = "No model loaded. Call /models/load first."
563
- if CONFIG.is_public:
564
- 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."
565
- return JSONResponse(
566
- status_code=400,
567
- content={
568
- "error": "no_model_loaded",
569
- "detail": detail,
570
- "message": detail,
571
- "action": "load_model",
572
- },
573
- )
524
+ return no_model_response()
574
525
 
575
526
  if req.model and req.model != router.current_model_id:
576
527
  if req.model not in router.loaded_model_ids:
@@ -578,32 +529,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
578
529
  router.switch_model(req.model)
579
530
 
580
531
  if is_file_action_request(req.message):
581
- agent_req = AgentRequest(
582
- message=req.message,
583
- conversation_id=req.conversation_id,
584
- source=req.source or "web",
585
- max_steps=25,
586
- temperature=min(req.temperature, 0.2),
587
- user_email=effective_email,
588
- user_nickname=req.user_nickname,
589
- workspace_id=workspace_scope_from_request(request),
590
- )
591
- result = await agent(agent_req, request)
592
- answer = str(result.get("response") or "파일 작업을 처리했습니다.")
593
- notify_chat_message("user", req.message, req.source)
594
- notify_chat_message("assistant", answer, req.source)
595
- result["routed_to_agent"] = True
596
- if req.stream:
597
- async def _stream_agent_result():
598
- yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
599
- yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
600
- yield "data: [DONE]\n\n"
601
- return StreamingResponse(
602
- _stream_agent_result(),
603
- media_type="text/event-stream",
604
- headers={"X-Model": router.current_model_id, "X-Routed-To": "agent"},
605
- )
606
- return JSONResponse(content=result)
532
+ return await route_file_action_to_agent(req, request, effective_email=effective_email)
607
533
 
608
534
  lang = detect_language(req.message)
609
535
  context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
@@ -638,7 +564,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
638
564
  graph_md = doc_gen_context_result.get("context_markdown", "")
639
565
  if graph_md:
640
566
  context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
641
- print("📝 Document generation context retrieved from knowledge graph.")
567
+ logging.debug("Document generation context retrieved from knowledge graph.")
642
568
  except Exception as e:
643
569
  logging.warning("Knowledge graph reinforcement skipped: %s", e)
644
570
 
@@ -678,7 +604,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
678
604
  trace_seed["context_assembly"] = context_trace
679
605
 
680
606
  history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
681
- save_to_history("user", history_message, **history_meta, **history_user)
607
+ await asyncio.to_thread(save_to_history, "user", history_message, **history_meta, **history_user)
682
608
  notify_chat_message("user", req.message, req.source)
683
609
 
684
610
  if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
@@ -695,19 +621,27 @@ def create_chat_router(context: AppContext) -> APIRouter:
695
621
  if req.stream:
696
622
  async def _stream_doc_gen():
697
623
  collected = []
698
- async for chunk in router.stream_generate_document(
699
- req.message, system_prompt,
700
- max_tokens=req.max_tokens or 8192,
701
- temperature=req.temperature or 0.3,
702
- ):
703
- collected.append(chunk)
704
- yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n"
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"
705
637
  full_text = "".join(collected)
706
638
  if footnote:
707
639
  yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
708
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}"
709
643
  session.update(graph_md, full_text, req.conversation_id)
710
- save_to_history("assistant", full_text, **history_meta, **history_user)
644
+ await asyncio.to_thread(save_to_history, "assistant", full_text, **history_meta, **history_user)
711
645
  trace_record = CHAT_SERVICE.record_trace(
712
646
  question=req.message,
713
647
  response=full_text,
@@ -732,7 +666,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
732
666
  if footnote:
733
667
  result += footnote
734
668
  session.update(graph_md, result, req.conversation_id)
735
- save_to_history("assistant", str(result), **history_meta, **history_user)
669
+ await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
736
670
  trace_record = CHAT_SERVICE.record_trace(
737
671
  question=req.message,
738
672
  response=str(result),
@@ -775,7 +709,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
775
709
 
776
710
  result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
777
711
 
778
- save_to_history("assistant", str(result), **history_meta, **history_user)
712
+ await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
779
713
  trace_record = CHAT_SERVICE.record_trace(
780
714
  question=req.message,
781
715
  response=str(result),
@@ -791,20 +725,20 @@ def create_chat_router(context: AppContext) -> APIRouter:
791
725
  @api_router.get("/history")
792
726
  async def fetch_history(request: Request):
793
727
  """웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
794
- require_user(request)
795
- return get_history()
728
+ current_user = require_user(request)
729
+ return get_history(**history_scope_for_user(current_user))
796
730
 
797
731
  @api_router.get("/history/conversations")
798
732
  async def fetch_history_conversations(request: Request):
799
733
  """저장된 히스토리를 대화 단위로 묶어 반환합니다."""
800
- require_user(request)
801
- return group_history_conversations()
734
+ current_user = require_user(request)
735
+ return group_history_conversations(get_history(**history_scope_for_user(current_user)))
802
736
 
803
737
  @api_router.get("/history/conversations/{conversation_id:path}")
804
738
  async def fetch_history_conversation(conversation_id: str, request: Request):
805
739
  """선택한 대화의 메시지를 반환합니다."""
806
- require_user(request)
807
- messages = get_conversation_messages(conversation_id)
740
+ current_user = require_user(request)
741
+ messages = get_conversation_messages(conversation_id, **history_scope_for_user(current_user))
808
742
  if not messages:
809
743
  raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
810
744
  return {"id": conversation_id, "messages": messages}
@@ -814,7 +748,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
814
748
  async def delete_history_conversation(conversation_id: str, request: Request):
815
749
  """선택한 대화방의 메시지만 삭제합니다."""
816
750
  email = require_user(request)
817
- result = clear_conversation(conversation_id, request.query_params.get("started_at"))
751
+ result = clear_conversation(
752
+ conversation_id,
753
+ request.query_params.get("started_at"),
754
+ **history_scope_for_user(email),
755
+ )
818
756
  append_audit_event(
819
757
  "conversation_delete",
820
758
  user_email=email,
@@ -829,7 +767,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
829
767
  @api_router.delete("/history")
830
768
  async def delete_history(request: Request, keep_last: int = 0):
831
769
  email = require_user(request)
832
- result = clear_history(keep_last)
770
+ result = clear_history(keep_last, **history_scope_for_user(email))
833
771
  append_audit_event(
834
772
  "history_delete",
835
773
  user_email=email,
@@ -842,11 +780,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
842
780
  @api_router.get("/history/search")
843
781
  async def search_history(q: str, request: Request):
844
782
  """키워드로 채팅 히스토리를 검색합니다."""
845
- require_user(request)
783
+ current_user = require_user(request)
846
784
  if not q or not q.strip():
847
785
  return {"results": [], "query": q}
848
786
  q_lower = q.strip().lower()
849
- history = get_history()
787
+ history = get_history(**history_scope_for_user(current_user))
850
788
  matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
851
789
  grouped: Dict[str, Dict] = {}
852
790
  for item in matches:
@@ -866,33 +804,45 @@ def create_chat_router(context: AppContext) -> APIRouter:
866
804
  history_meta: Optional[Dict] = None,
867
805
  ) -> AsyncIterator[str]:
868
806
  full_response = ""
869
- async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
870
- clean_chunk = chunk
871
- if hasattr(chunk, "text"):
872
- clean_chunk = chunk.text
873
- elif isinstance(chunk, str) and "text='" in chunk:
874
- try:
875
- clean_chunk = chunk.split("text='")[1].split("', token=")[0].replace('\\n', '\n').replace('\\\\n', '\n')
876
- except Exception:
877
- pass
878
-
879
- full_response += str(clean_chunk)
880
- yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
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"
881
817
  history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
882
- save_to_history("assistant", full_response, **(history_meta or {}), **history_user)
883
- trace_record = CHAT_SERVICE.record_trace(
884
- question=req.message,
885
- response=full_response,
886
- conversation_id=req.conversation_id,
887
- user_email=effective_email or req.user_email,
888
- trace=trace_seed or CHAT_SERVICE.build_graph_trace(
889
- req.message,
890
- KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
891
- context,
892
- ),
893
- )
894
- notify_chat_message("assistant", full_response, req.source)
895
- yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
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"
896
846
  yield "data: [DONE]\n\n"
897
847
 
898
848
  @api_router.post("/agent/eval")
@@ -991,7 +941,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
991
941
  }
992
942
 
993
943
  # Auto-approve and run to completion (default behaviour)
994
- _AGENT_RUNTIME.approve(ctx, current_user)
944
+ _AGENT_RUNTIME.approve(ctx, current_user, approved_by_human=True)
995
945
  return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
996
946
 
997
947
 
@@ -1001,11 +951,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
1001
951
  ) -> dict:
1002
952
  """HTTP glue: drive the runtime to a terminal state, persist, shape the response."""
1003
953
  await _AGENT_RUNTIME.run_to_completion(ctx, req, lang_hint, current_user, max_steps, max_retry)
1004
- asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
954
+ schedule_background_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
1005
955
 
1006
956
  message = ctx.final_message or "작업을 완료했습니다."
1007
- save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
1008
- save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
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)
1009
959
  try:
1010
960
  WORKSPACE_OS.record_agent_run(
1011
961
  agent_id="agent:executor",