ltcai 9.0.0 → 9.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -46
- package/auto_setup.py +7 -853
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +178 -2
- package/docs/COMMUNITY_AND_PLUGINS.md +4 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +1 -6
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/graph/_kg_fsutil.py +1 -5
- package/lattice_brain/graph/discovery_index.py +174 -20
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +140 -39
- package/lattice_brain/graph/retrieval_docgen.py +37 -4
- package/lattice_brain/ingestion.py +4 -7
- package/lattice_brain/portability.py +28 -14
- package/lattice_brain/runtime/agent_runtime.py +5 -9
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +20 -2
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +320 -850
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +28 -1
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +316 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +62 -9
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +67 -26
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +96 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +110 -52
- package/latticeai/core/agent.py +50 -13
- package/latticeai/core/agent_prompts.py +5 -0
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/file_generation.py +451 -0
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +9 -1
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +1 -5
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +0 -5
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +94 -43
- package/latticeai/models/router.py +130 -36
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/automation_runtime.py +13 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +62 -72
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +55 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +83 -23
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +12 -0
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +4 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +1 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1260
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/Act-C3dBrWE-.js +1 -0
- package/static/app/assets/Brain-DBYgdcjt.js +321 -0
- package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
- package/static/app/assets/Library-CFfkNn3s.js +1 -0
- package/static/app/assets/System-BOurbT-v.js +1 -0
- package/static/app/assets/index-A3M9sElj.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-DcUUmhdC.js +1 -0
- package/static/app/assets/textarea-BklR6zN4.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-21lIXx2E.js +0 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
- package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
- package/static/app/assets/Library-bFMtyni3.js +0 -1
- package/static/app/assets/System-K6krGCqn.js +0 -1
- package/static/app/assets/index-C4R3ws30.js +0 -17
- package/static/app/assets/index-ChSeOB02.css +0 -2
- package/static/app/assets/primitives-sQU3it5I.js +0 -1
- package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
- package/tools/knowledge.py +0 -95
|
@@ -26,6 +26,13 @@ def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
|
|
|
26
26
|
for item in history:
|
|
27
27
|
if item.get("role") == "assistant":
|
|
28
28
|
if include_next_assistant:
|
|
29
|
+
assistant_user = item.get("user_email")
|
|
30
|
+
if assistant_user and assistant_user != user_email:
|
|
31
|
+
# Concurrent requests can interleave rows in the global
|
|
32
|
+
# history order. A reply explicitly owned by somebody
|
|
33
|
+
# else is never this user's reply; keep waiting for the
|
|
34
|
+
# next correctly-owned (or legacy ownerless) assistant.
|
|
35
|
+
continue
|
|
29
36
|
paired.append(item)
|
|
30
37
|
include_next_assistant = False
|
|
31
38
|
elif item.get("user_email") == user_email:
|
|
@@ -121,6 +128,10 @@ def is_file_action_request(text: str) -> bool:
|
|
|
121
128
|
has_target = bool(_FILE_TARGET_RE.search(raw))
|
|
122
129
|
has_file_word = any(word in lower for word in (
|
|
123
130
|
"file", "파일", "문서", "artifact", "아티팩트", "save as", "저장",
|
|
131
|
+
# Explicit artifact types: users asking for "an html page" expect a
|
|
132
|
+
# real file, not a code block in chat — route them to the file path
|
|
133
|
+
# where the model-agnostic generation pipeline guarantees validity.
|
|
134
|
+
"html", "웹페이지", "웹 페이지", "홈페이지", "webpage", "web page",
|
|
124
135
|
))
|
|
125
136
|
has_action = any(word in lower for word in (
|
|
126
137
|
"create", "make", "write", "save", "generate", "edit", "update",
|
|
@@ -205,8 +216,24 @@ def build_recent_chat_context(
|
|
|
205
216
|
include_image_missing_replies: bool = True,
|
|
206
217
|
user_email: Optional[str] = None,
|
|
207
218
|
conversation_id: Optional[str] = None,
|
|
219
|
+
workspace_id: Optional[str] = None,
|
|
208
220
|
) -> str:
|
|
209
|
-
|
|
221
|
+
if user_email:
|
|
222
|
+
# Apply identity/workspace isolation in the durable query rather than
|
|
223
|
+
# relying only on positional post-filtering. In particular, never
|
|
224
|
+
# admit legacy-global rows into an authenticated model prompt.
|
|
225
|
+
history = get_history(
|
|
226
|
+
user_email=user_email,
|
|
227
|
+
allowed_workspaces={workspace_id} if workspace_id is not None else None,
|
|
228
|
+
include_legacy_global=False,
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
history = get_history()
|
|
232
|
+
if workspace_id is not None:
|
|
233
|
+
history = [
|
|
234
|
+
item for item in history
|
|
235
|
+
if str(item.get("workspace_id") or "personal") == str(workspace_id)
|
|
236
|
+
]
|
|
210
237
|
if conversation_id:
|
|
211
238
|
history = [item for item in history if item.get("conversation_id") == conversation_id]
|
|
212
239
|
if user_email:
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Conversation-history HTTP routes.
|
|
2
|
+
|
|
3
|
+
Persistence and search grouping live in :mod:`latticeai.services.chat_service`;
|
|
4
|
+
this module only authenticates requests and shapes HTTP responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Callable, Dict
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class HistoryRouteDependencies:
|
|
17
|
+
chat_service: Any
|
|
18
|
+
require_user: Callable[[Request], str]
|
|
19
|
+
scope_for_user: Callable[[str], Dict[str, Any]]
|
|
20
|
+
group_conversations: Callable[[list], Any]
|
|
21
|
+
get_conversation_messages: Callable[..., list]
|
|
22
|
+
conversation_title: Callable[[Dict[str, Any]], str]
|
|
23
|
+
clear_conversation: Callable[..., Dict[str, Any]]
|
|
24
|
+
clear_history: Callable[..., Dict[str, Any]]
|
|
25
|
+
append_audit_event: Callable[..., None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register_history_routes(router: APIRouter, deps: HistoryRouteDependencies) -> None:
|
|
29
|
+
@router.get("/history")
|
|
30
|
+
async def fetch_history(request: Request):
|
|
31
|
+
"""웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
|
|
32
|
+
current_user = deps.require_user(request)
|
|
33
|
+
return deps.chat_service.history(**deps.scope_for_user(current_user))
|
|
34
|
+
|
|
35
|
+
@router.get("/history/conversations")
|
|
36
|
+
async def fetch_history_conversations(request: Request):
|
|
37
|
+
"""저장된 히스토리를 대화 단위로 묶어 반환합니다."""
|
|
38
|
+
current_user = deps.require_user(request)
|
|
39
|
+
history = deps.chat_service.history(**deps.scope_for_user(current_user))
|
|
40
|
+
return deps.group_conversations(history)
|
|
41
|
+
|
|
42
|
+
@router.get("/history/conversations/{conversation_id:path}")
|
|
43
|
+
async def fetch_history_conversation(conversation_id: str, request: Request):
|
|
44
|
+
"""선택한 대화의 메시지를 반환합니다."""
|
|
45
|
+
current_user = deps.require_user(request)
|
|
46
|
+
messages = deps.get_conversation_messages(
|
|
47
|
+
conversation_id,
|
|
48
|
+
**deps.scope_for_user(current_user),
|
|
49
|
+
)
|
|
50
|
+
if not messages:
|
|
51
|
+
raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
|
|
52
|
+
return {"id": conversation_id, "messages": messages}
|
|
53
|
+
|
|
54
|
+
@router.delete("/history/conversations/{conversation_id:path}")
|
|
55
|
+
async def delete_history_conversation(conversation_id: str, request: Request):
|
|
56
|
+
"""선택한 대화방의 메시지만 삭제합니다."""
|
|
57
|
+
email = deps.require_user(request)
|
|
58
|
+
started_at = request.query_params.get("started_at")
|
|
59
|
+
result = deps.clear_conversation(
|
|
60
|
+
conversation_id,
|
|
61
|
+
started_at,
|
|
62
|
+
**deps.scope_for_user(email),
|
|
63
|
+
)
|
|
64
|
+
deps.append_audit_event(
|
|
65
|
+
"conversation_delete",
|
|
66
|
+
user_email=email,
|
|
67
|
+
conversation_id=conversation_id,
|
|
68
|
+
started_at=started_at,
|
|
69
|
+
removed=result.get("removed", 0),
|
|
70
|
+
kept=result.get("kept", 0),
|
|
71
|
+
)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
@router.delete("/history")
|
|
75
|
+
async def delete_history(request: Request, keep_last: int = 0):
|
|
76
|
+
email = deps.require_user(request)
|
|
77
|
+
result = deps.clear_history(keep_last, **deps.scope_for_user(email))
|
|
78
|
+
deps.append_audit_event(
|
|
79
|
+
"history_delete",
|
|
80
|
+
user_email=email,
|
|
81
|
+
keep_last=keep_last,
|
|
82
|
+
removed=result.get("removed", 0),
|
|
83
|
+
kept=result.get("kept", 0),
|
|
84
|
+
)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
@router.get("/history/search")
|
|
88
|
+
async def search_history(q: str, request: Request):
|
|
89
|
+
"""키워드로 채팅 히스토리를 검색합니다."""
|
|
90
|
+
current_user = deps.require_user(request)
|
|
91
|
+
results = deps.chat_service.search_history(
|
|
92
|
+
q,
|
|
93
|
+
scope=deps.scope_for_user(current_user),
|
|
94
|
+
conversation_title=deps.conversation_title,
|
|
95
|
+
)
|
|
96
|
+
return {"results": results, "query": q}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = ["HistoryRouteDependencies", "register_history_routes"]
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Fast-path intent and governed file-action handlers for chat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException, Request
|
|
10
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
11
|
+
|
|
12
|
+
from latticeai.api.chat_contracts import AgentRequest
|
|
13
|
+
from latticeai.api.chat_helpers import (
|
|
14
|
+
file_action_target,
|
|
15
|
+
format_network_status,
|
|
16
|
+
inline_file_action_content,
|
|
17
|
+
)
|
|
18
|
+
from latticeai.api.chat_stream import agent_payload_stream, single_answer_response
|
|
19
|
+
from latticeai.core.agent import AgentState
|
|
20
|
+
from latticeai.core.file_generation import generate_file_content, infer_file_target
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ChatIntentController:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
model_router: Any,
|
|
28
|
+
config: Any,
|
|
29
|
+
public_model: str,
|
|
30
|
+
chat_service: Any,
|
|
31
|
+
notify: Any,
|
|
32
|
+
clear_history: Any,
|
|
33
|
+
clear_conversation: Any,
|
|
34
|
+
history_scope_for_user: Any,
|
|
35
|
+
append_audit_event: Any,
|
|
36
|
+
enable_graph: bool,
|
|
37
|
+
knowledge_graph: Any,
|
|
38
|
+
enforce_tool_policy: Any,
|
|
39
|
+
network_status: Any,
|
|
40
|
+
tool_error: type[Exception],
|
|
41
|
+
execute_tool: Any,
|
|
42
|
+
agent_controller: Any,
|
|
43
|
+
agent_root: Path,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.router = model_router
|
|
46
|
+
self.config = config
|
|
47
|
+
self.public_model = public_model
|
|
48
|
+
self.chat_service = chat_service
|
|
49
|
+
self.notify = notify
|
|
50
|
+
self.clear_history = clear_history
|
|
51
|
+
self.clear_conversation = clear_conversation
|
|
52
|
+
self.history_scope_for_user = history_scope_for_user
|
|
53
|
+
self.append_audit_event = append_audit_event
|
|
54
|
+
self.enable_graph = enable_graph
|
|
55
|
+
self.knowledge_graph = knowledge_graph
|
|
56
|
+
self.enforce_tool_policy = enforce_tool_policy
|
|
57
|
+
self.network_status = network_status
|
|
58
|
+
self.tool_error = tool_error
|
|
59
|
+
self.execute_tool = execute_tool
|
|
60
|
+
self.agent_controller = agent_controller
|
|
61
|
+
self.agent_root = Path(agent_root)
|
|
62
|
+
|
|
63
|
+
def no_model_response(self) -> JSONResponse:
|
|
64
|
+
detail = "No model loaded. Call /models/load first."
|
|
65
|
+
if self.config.is_public:
|
|
66
|
+
detail = (
|
|
67
|
+
"No public model loaded. Set OPENAI_API_KEY and "
|
|
68
|
+
f"LATTICEAI_PUBLIC_MODEL={self.public_model}, or call /models/load "
|
|
69
|
+
"with an OpenAI-compatible model."
|
|
70
|
+
)
|
|
71
|
+
return JSONResponse(
|
|
72
|
+
status_code=400,
|
|
73
|
+
content={
|
|
74
|
+
"error": "no_model_loaded",
|
|
75
|
+
"detail": detail,
|
|
76
|
+
"message": detail,
|
|
77
|
+
"action": "load_model",
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def network(
|
|
82
|
+
self,
|
|
83
|
+
req: Any,
|
|
84
|
+
*,
|
|
85
|
+
current_user: str,
|
|
86
|
+
history_meta: Dict[str, Any],
|
|
87
|
+
history_user: Dict[str, Any],
|
|
88
|
+
):
|
|
89
|
+
history_message = (
|
|
90
|
+
f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
91
|
+
)
|
|
92
|
+
self.enforce_tool_policy(
|
|
93
|
+
"network_status",
|
|
94
|
+
{},
|
|
95
|
+
current_user=current_user,
|
|
96
|
+
source="chat_intent",
|
|
97
|
+
trusted_admin=not bool(getattr(self.config, "require_auth", False)),
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
answer = format_network_status(self.network_status())
|
|
101
|
+
except self.tool_error as exc:
|
|
102
|
+
answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
|
|
103
|
+
await self.chat_service.persist_exchange(
|
|
104
|
+
request_message=req.message,
|
|
105
|
+
stored_user_message=history_message,
|
|
106
|
+
answer=answer,
|
|
107
|
+
source=req.source,
|
|
108
|
+
history_meta=history_meta,
|
|
109
|
+
history_user=history_user,
|
|
110
|
+
notify=self.notify,
|
|
111
|
+
)
|
|
112
|
+
return single_answer_response(req, answer, model="network_status")
|
|
113
|
+
|
|
114
|
+
async def clear(
|
|
115
|
+
self,
|
|
116
|
+
req: Any,
|
|
117
|
+
*,
|
|
118
|
+
effective_email: Optional[str],
|
|
119
|
+
workspace_id: Optional[str],
|
|
120
|
+
):
|
|
121
|
+
command = req.message.strip().lower()
|
|
122
|
+
clear_scope = "all" if command == "/clear_all" else "conversation"
|
|
123
|
+
if self.enable_graph and self.knowledge_graph:
|
|
124
|
+
try:
|
|
125
|
+
self.knowledge_graph.ingest_event(
|
|
126
|
+
"ClearEvent",
|
|
127
|
+
f"{command} requested",
|
|
128
|
+
user_email=effective_email,
|
|
129
|
+
user_nickname=req.user_nickname,
|
|
130
|
+
source=req.source or "web",
|
|
131
|
+
conversation_id=req.conversation_id,
|
|
132
|
+
workspace_id=workspace_id,
|
|
133
|
+
metadata={"command": command, "scope": clear_scope},
|
|
134
|
+
)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
# Clear remains available even when optional graph audit ingest
|
|
137
|
+
# is unhealthy; the primary audit event below is authoritative.
|
|
138
|
+
logging.warning("knowledge graph clear event ingest failed: %s", exc)
|
|
139
|
+
scope = self.history_scope_for_user(effective_email)
|
|
140
|
+
if command == "/clear_all":
|
|
141
|
+
result = self.clear_history(0, **scope)
|
|
142
|
+
prefix = "채팅창을 정리했습니다."
|
|
143
|
+
elif req.conversation_id:
|
|
144
|
+
result = self.clear_conversation(req.conversation_id, **scope)
|
|
145
|
+
prefix = "현재 대화방 채팅창을 정리했습니다."
|
|
146
|
+
else:
|
|
147
|
+
result = self.clear_history(0, **scope)
|
|
148
|
+
prefix = "채팅창을 정리했습니다."
|
|
149
|
+
answer = (
|
|
150
|
+
f"{prefix} 화면에서 제거 {result.get('removed', 0)}개. "
|
|
151
|
+
"감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
152
|
+
)
|
|
153
|
+
self.append_audit_event(
|
|
154
|
+
"clear_command",
|
|
155
|
+
user_email=effective_email,
|
|
156
|
+
user_nickname=req.user_nickname,
|
|
157
|
+
source=req.source or "web",
|
|
158
|
+
conversation_id=req.conversation_id,
|
|
159
|
+
command=command,
|
|
160
|
+
scope=clear_scope,
|
|
161
|
+
removed=result.get("removed", 0),
|
|
162
|
+
kept=result.get("kept", 0),
|
|
163
|
+
)
|
|
164
|
+
self.notify("user", req.message, req.source)
|
|
165
|
+
self.notify("assistant", answer, req.source)
|
|
166
|
+
return single_answer_response(req, answer, model="history")
|
|
167
|
+
|
|
168
|
+
async def current_url(
|
|
169
|
+
self,
|
|
170
|
+
req: Any,
|
|
171
|
+
*,
|
|
172
|
+
history_meta: Dict[str, Any],
|
|
173
|
+
history_user: Dict[str, Any],
|
|
174
|
+
):
|
|
175
|
+
answer = f"현재 페이지 URL: {req.client_url}"
|
|
176
|
+
await self.chat_service.persist_exchange(
|
|
177
|
+
request_message=req.message,
|
|
178
|
+
stored_user_message=req.message,
|
|
179
|
+
answer=answer,
|
|
180
|
+
source=req.source,
|
|
181
|
+
history_meta=history_meta,
|
|
182
|
+
history_user=history_user,
|
|
183
|
+
notify=self.notify,
|
|
184
|
+
)
|
|
185
|
+
return single_answer_response(req, answer, model="client_url")
|
|
186
|
+
|
|
187
|
+
async def direct_file_action(self, req: Any, *, model_id: Optional[str]):
|
|
188
|
+
# An explicit path ("report.txt") wins; otherwise infer one from an
|
|
189
|
+
# explicit type keyword ("html 파일 만들어줘") so weak models never
|
|
190
|
+
# have to drive the JSON tool loop just to create a file.
|
|
191
|
+
target_path = file_action_target(req.message) or infer_file_target(req.message)
|
|
192
|
+
if not target_path:
|
|
193
|
+
return None
|
|
194
|
+
content = inline_file_action_content(req.message)
|
|
195
|
+
generation_meta: Optional[Dict[str, Any]] = None
|
|
196
|
+
if content is None and not model_id:
|
|
197
|
+
return self.no_model_response()
|
|
198
|
+
if content is None and model_id:
|
|
199
|
+
# Model-agnostic pipeline: strict extension-aware prompt →
|
|
200
|
+
# extraction → validation → one corrective retry → deterministic
|
|
201
|
+
# repair. Guarantees a structurally valid file from any LLM.
|
|
202
|
+
async def _generate(context: str) -> str:
|
|
203
|
+
return str(
|
|
204
|
+
await self.router.generate_as(
|
|
205
|
+
model_id,
|
|
206
|
+
message="Return only the requested file content.",
|
|
207
|
+
context=context,
|
|
208
|
+
max_tokens=max(int(req.max_tokens or 0), 4096),
|
|
209
|
+
temperature=min(req.temperature, 0.3),
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
content, generation_meta = await generate_file_content(
|
|
214
|
+
_generate,
|
|
215
|
+
target_path=target_path,
|
|
216
|
+
user_request=req.message,
|
|
217
|
+
)
|
|
218
|
+
if content is None:
|
|
219
|
+
raise HTTPException(
|
|
220
|
+
status_code=400,
|
|
221
|
+
detail="File content could not be generated.",
|
|
222
|
+
)
|
|
223
|
+
try:
|
|
224
|
+
result = self.execute_tool(
|
|
225
|
+
"write_file",
|
|
226
|
+
{"path": target_path, "content": content},
|
|
227
|
+
)
|
|
228
|
+
except self.tool_error as exc:
|
|
229
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
230
|
+
answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
|
|
231
|
+
if generation_meta and generation_meta.get("repaired"):
|
|
232
|
+
answer += (
|
|
233
|
+
" (모델 출력이 불완전해 자동 보정을 거쳐 유효한 파일로 저장했습니다.)"
|
|
234
|
+
)
|
|
235
|
+
created_files = [
|
|
236
|
+
{
|
|
237
|
+
"path": result.get("path") or target_path,
|
|
238
|
+
"filename": Path(result.get("path") or target_path).name,
|
|
239
|
+
"bytes": result.get("bytes", 0),
|
|
240
|
+
"action": "write_file",
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
self.notify("user", req.message, req.source)
|
|
244
|
+
self.notify("assistant", answer, req.source)
|
|
245
|
+
payload = {
|
|
246
|
+
"status": "ok",
|
|
247
|
+
"response": answer,
|
|
248
|
+
"workspace": str(self.agent_root),
|
|
249
|
+
"steps": [
|
|
250
|
+
{
|
|
251
|
+
"state": AgentState.EXECUTING.value,
|
|
252
|
+
"action": "write_file",
|
|
253
|
+
"args": {"path": target_path},
|
|
254
|
+
"result": result,
|
|
255
|
+
}
|
|
256
|
+
],
|
|
257
|
+
"state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
|
|
258
|
+
"final_state": AgentState.DONE.value,
|
|
259
|
+
"created_files": created_files,
|
|
260
|
+
"routed_to_agent": True,
|
|
261
|
+
"action_route": "direct_write_file",
|
|
262
|
+
}
|
|
263
|
+
if generation_meta is not None:
|
|
264
|
+
payload["generation"] = generation_meta
|
|
265
|
+
if req.stream:
|
|
266
|
+
return StreamingResponse(
|
|
267
|
+
agent_payload_stream(
|
|
268
|
+
answer,
|
|
269
|
+
payload,
|
|
270
|
+
router=self.router,
|
|
271
|
+
model_id=model_id,
|
|
272
|
+
),
|
|
273
|
+
media_type="text/event-stream",
|
|
274
|
+
headers={"X-Model": model_id or "tool", "X-Routed-To": "agent"},
|
|
275
|
+
)
|
|
276
|
+
return JSONResponse(content=payload)
|
|
277
|
+
|
|
278
|
+
async def route_file_to_agent(
|
|
279
|
+
self,
|
|
280
|
+
req: Any,
|
|
281
|
+
request: Request,
|
|
282
|
+
*,
|
|
283
|
+
effective_email: Optional[str],
|
|
284
|
+
workspace_id: Optional[str],
|
|
285
|
+
model_id: Optional[str],
|
|
286
|
+
):
|
|
287
|
+
agent_request = AgentRequest(
|
|
288
|
+
message=req.message,
|
|
289
|
+
conversation_id=req.conversation_id,
|
|
290
|
+
source=req.source or "web",
|
|
291
|
+
max_steps=25,
|
|
292
|
+
temperature=min(req.temperature, 0.2),
|
|
293
|
+
user_email=effective_email,
|
|
294
|
+
user_nickname=req.user_nickname,
|
|
295
|
+
workspace_id=workspace_id,
|
|
296
|
+
)
|
|
297
|
+
result = await self.agent_controller.agent(agent_request, request)
|
|
298
|
+
answer = str(result.get("response") or "파일 작업을 처리했습니다.")
|
|
299
|
+
self.notify("user", req.message, req.source)
|
|
300
|
+
self.notify("assistant", answer, req.source)
|
|
301
|
+
result["routed_to_agent"] = True
|
|
302
|
+
if req.stream:
|
|
303
|
+
return StreamingResponse(
|
|
304
|
+
agent_payload_stream(
|
|
305
|
+
answer,
|
|
306
|
+
result,
|
|
307
|
+
router=self.router,
|
|
308
|
+
model_id=model_id,
|
|
309
|
+
),
|
|
310
|
+
media_type="text/event-stream",
|
|
311
|
+
headers={"X-Model": model_id or "agent", "X-Routed-To": "agent"},
|
|
312
|
+
)
|
|
313
|
+
return JSONResponse(content=result)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
__all__ = ["ChatIntentController"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""SSE framing and streamed-answer finalization for chat routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, AsyncIterator, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
10
|
+
|
|
11
|
+
from latticeai.api.chat_helpers import single_text_stream
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def single_answer_response(req: Any, answer: str, *, model: str):
|
|
15
|
+
if req.stream:
|
|
16
|
+
return StreamingResponse(
|
|
17
|
+
single_text_stream(answer),
|
|
18
|
+
media_type="text/event-stream",
|
|
19
|
+
headers={"X-Model": model},
|
|
20
|
+
)
|
|
21
|
+
return JSONResponse(content={"response": answer})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def agent_payload_stream(
|
|
25
|
+
answer: str,
|
|
26
|
+
payload: Dict[str, Any],
|
|
27
|
+
*,
|
|
28
|
+
router: Any,
|
|
29
|
+
model_id: Optional[str] = None,
|
|
30
|
+
) -> AsyncIterator[str]:
|
|
31
|
+
async def _stream() -> AsyncIterator[str]:
|
|
32
|
+
response_model = model_id or router.current_model_id
|
|
33
|
+
yield f"data: {json.dumps({'chunk': answer, 'model': response_model, 'agent': payload}, ensure_ascii=False)}\n\n"
|
|
34
|
+
yield f"data: {json.dumps({'chunk': '', 'model': response_model, 'agent': payload}, ensure_ascii=False)}\n\n"
|
|
35
|
+
yield "data: [DONE]\n\n"
|
|
36
|
+
|
|
37
|
+
return _stream()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def stream_chat(
|
|
41
|
+
req: Any,
|
|
42
|
+
context: str,
|
|
43
|
+
image_data: Optional[str],
|
|
44
|
+
*,
|
|
45
|
+
router: Any,
|
|
46
|
+
chat_service: Any,
|
|
47
|
+
knowledge_graph: Any,
|
|
48
|
+
enable_graph: bool,
|
|
49
|
+
notify: Any,
|
|
50
|
+
trace_seed: Optional[Dict[str, Any]] = None,
|
|
51
|
+
effective_email: Optional[str] = None,
|
|
52
|
+
history_meta: Optional[Dict[str, Any]] = None,
|
|
53
|
+
model_id: Optional[str] = None,
|
|
54
|
+
workspace_id: Optional[str] = None,
|
|
55
|
+
) -> AsyncIterator[str]:
|
|
56
|
+
"""Stream model chunks and persist exactly one finalized answer."""
|
|
57
|
+
|
|
58
|
+
full_response = ""
|
|
59
|
+
stream_error: Optional[str] = None
|
|
60
|
+
try:
|
|
61
|
+
async for chunk in router.stream_generate_as(
|
|
62
|
+
model_id,
|
|
63
|
+
req.message,
|
|
64
|
+
context,
|
|
65
|
+
req.max_tokens,
|
|
66
|
+
req.temperature,
|
|
67
|
+
image_data,
|
|
68
|
+
):
|
|
69
|
+
clean_chunk = chunk.text if hasattr(chunk, "text") else chunk
|
|
70
|
+
full_response += str(clean_chunk)
|
|
71
|
+
yield f"data: {json.dumps({'chunk': clean_chunk, 'model': model_id}, ensure_ascii=False)}\n\n"
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
stream_error = str(exc)
|
|
74
|
+
logging.warning("chat stream failed: %s", exc)
|
|
75
|
+
yield f"data: {json.dumps({'error': stream_error, 'model': model_id}, ensure_ascii=False)}\n\n"
|
|
76
|
+
|
|
77
|
+
persisted_response = full_response
|
|
78
|
+
if stream_error and not persisted_response:
|
|
79
|
+
persisted_response = f"[stream_error] {stream_error}"
|
|
80
|
+
elif stream_error:
|
|
81
|
+
persisted_response = f"{persisted_response}\n\n[stream_error] {stream_error}"
|
|
82
|
+
|
|
83
|
+
trace_record = None
|
|
84
|
+
try:
|
|
85
|
+
answer_trace = trace_seed or chat_service.build_graph_trace(
|
|
86
|
+
req.message,
|
|
87
|
+
knowledge_graph if (enable_graph and knowledge_graph) else None,
|
|
88
|
+
context,
|
|
89
|
+
allowed_workspaces={workspace_id} if workspace_id else None,
|
|
90
|
+
)
|
|
91
|
+
trace_record = await chat_service.persist_answer(
|
|
92
|
+
question=req.message,
|
|
93
|
+
response=persisted_response,
|
|
94
|
+
conversation_id=req.conversation_id,
|
|
95
|
+
user_email=effective_email or req.user_email,
|
|
96
|
+
user_nickname=req.user_nickname,
|
|
97
|
+
source=req.source,
|
|
98
|
+
trace=answer_trace,
|
|
99
|
+
workspace_id=workspace_id,
|
|
100
|
+
history_meta=history_meta or {},
|
|
101
|
+
notify=notify,
|
|
102
|
+
)
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
logging.warning("chat stream persistence failed: %s", exc)
|
|
105
|
+
|
|
106
|
+
trailer: Dict[str, Any] = {"chunk": "", "model": model_id}
|
|
107
|
+
if trace_record:
|
|
108
|
+
trailer.update({"trace_id": trace_record["id"], "trace": trace_record})
|
|
109
|
+
if stream_error:
|
|
110
|
+
trailer["error"] = stream_error
|
|
111
|
+
yield f"data: {json.dumps(trailer, ensure_ascii=False)}\n\n"
|
|
112
|
+
yield "data: [DONE]\n\n"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
__all__ = ["agent_payload_stream", "single_answer_response", "stream_chat"]
|