ltcai 3.6.0 → 4.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.
- package/README.md +11 -7
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +47 -53
- package/kg_schema.py +93 -10
- package/knowledge_graph.py +362 -33
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agents.py +7 -1
- package/latticeai/api/auth.py +27 -4
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/knowledge_graph.py +146 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/realtime.py +1 -1
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +2 -4
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/workflow_designer.py +46 -0
- package/latticeai/api/workspace.py +71 -49
- package/latticeai/app_factory.py +1710 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +37 -7
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +58 -10
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1536
- package/latticeai/services/agent_runtime.py +1 -0
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +47 -0
- package/latticeai/services/kg_portability.py +33 -3
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +31 -0
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +9 -7
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +82 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/account.html +2 -4
- package/static/admin.html +3 -5
- package/static/chat.html +3 -6
- package/static/graph.html +2 -4
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +20 -19
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.c541f955.js → app.356e6452.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
- package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
- package/static/v3/js/core/routes.js +22 -22
- package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
- package/static/v3/js/core/shell.js +1 -1
- package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
- package/static/v3/js/core/store.js +1 -1
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.5e40cbeb.js} +33 -37
- package/static/v3/js/views/knowledge-graph.js +33 -37
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/static/workspace.html +2 -2
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/requirements.txt +0 -16
package/latticeai/__init__.py
CHANGED
package/latticeai/api/admin.py
CHANGED
package/latticeai/api/agents.py
CHANGED
|
@@ -163,7 +163,13 @@ def create_agents_router(
|
|
|
163
163
|
current_user = require_user(request)
|
|
164
164
|
scope = gate_write(request)
|
|
165
165
|
try:
|
|
166
|
-
|
|
166
|
+
# Worker thread: an LLM-backed run blocks on model generation and
|
|
167
|
+
# must not stall the event loop (the sync model bridge also
|
|
168
|
+
# requires a loop-free thread).
|
|
169
|
+
import asyncio
|
|
170
|
+
|
|
171
|
+
return await asyncio.to_thread(
|
|
172
|
+
runtime.start,
|
|
167
173
|
req.goal,
|
|
168
174
|
user_email=current_user or None,
|
|
169
175
|
scope=scope,
|
package/latticeai/api/auth.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Authentication API router: register, login, logout, SSO, profile."""
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
3
5
|
import logging
|
|
4
6
|
import secrets
|
|
5
7
|
import time
|
|
@@ -69,11 +71,22 @@ def create_auth_router(
|
|
|
69
71
|
) -> APIRouter:
|
|
70
72
|
router = APIRouter()
|
|
71
73
|
|
|
74
|
+
def _enforce_password_policy(password: str) -> None:
|
|
75
|
+
# Real policy (v4): length >= 8 with letters AND digits. A 4-char
|
|
76
|
+
# minimum was not a policy.
|
|
77
|
+
pw = str(password or "")
|
|
78
|
+
if len(pw) < 8 or not any(c.isalpha() for c in pw) or not any(c.isdigit() for c in pw):
|
|
79
|
+
raise HTTPException(
|
|
80
|
+
status_code=400,
|
|
81
|
+
detail="비밀번호는 8자 이상이며 영문자와 숫자를 모두 포함해야 합니다.",
|
|
82
|
+
)
|
|
83
|
+
|
|
72
84
|
@router.post("/register")
|
|
73
85
|
async def register(req: UserRegister, request: Request):
|
|
74
86
|
check_ip_rate_limit(client_ip(request), "register", max_calls=5, window_secs=3600)
|
|
75
87
|
if not open_registration:
|
|
76
88
|
raise HTTPException(status_code=403, detail="회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.")
|
|
89
|
+
_enforce_password_policy(req.password)
|
|
77
90
|
users = load_users()
|
|
78
91
|
if req.email in users:
|
|
79
92
|
raise HTTPException(status_code=400, detail="이미 존재하는 이메일입니다.")
|
|
@@ -123,7 +136,15 @@ def create_auth_router(
|
|
|
123
136
|
raise HTTPException(status_code=503, detail="SSO가 설정되지 않았습니다.")
|
|
124
137
|
state = secrets.token_urlsafe(16)
|
|
125
138
|
nonce = secrets.token_urlsafe(16)
|
|
126
|
-
|
|
139
|
+
# PKCE (S256): bind the token exchange to this login, so an
|
|
140
|
+
# intercepted authorization code is useless without the verifier.
|
|
141
|
+
code_verifier = secrets.token_urlsafe(48)
|
|
142
|
+
code_challenge = (
|
|
143
|
+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
|
|
144
|
+
.rstrip(b"=")
|
|
145
|
+
.decode("ascii")
|
|
146
|
+
)
|
|
147
|
+
_sso_states[state] = (time.time(), nonce, code_verifier)
|
|
127
148
|
params = urlencode({
|
|
128
149
|
"client_id": settings["client_id"],
|
|
129
150
|
"response_type": "code",
|
|
@@ -131,6 +152,8 @@ def create_auth_router(
|
|
|
131
152
|
"scope": settings.get("scopes") or "openid email profile",
|
|
132
153
|
"state": state,
|
|
133
154
|
"nonce": nonce,
|
|
155
|
+
"code_challenge": code_challenge,
|
|
156
|
+
"code_challenge_method": "S256",
|
|
134
157
|
})
|
|
135
158
|
return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
|
|
136
159
|
|
|
@@ -141,7 +164,7 @@ def create_auth_router(
|
|
|
141
164
|
entry = _sso_states.pop(state, None)
|
|
142
165
|
if entry is None or time.time() - entry[0] > 300:
|
|
143
166
|
raise HTTPException(status_code=400, detail="유효하지 않은 SSO 상태입니다.")
|
|
144
|
-
_, nonce = entry
|
|
167
|
+
_, nonce, code_verifier = entry
|
|
145
168
|
settings = get_sso_settings()
|
|
146
169
|
discovery = await get_sso_discovery()
|
|
147
170
|
if not settings.get("enabled") or not discovery:
|
|
@@ -154,6 +177,7 @@ def create_auth_router(
|
|
|
154
177
|
"redirect_uri": settings["redirect_uri"],
|
|
155
178
|
"client_id": settings["client_id"],
|
|
156
179
|
"client_secret": settings["client_secret"],
|
|
180
|
+
"code_verifier": code_verifier,
|
|
157
181
|
}, headers={"Accept": "application/json"}, timeout=15)
|
|
158
182
|
tokens = r.json()
|
|
159
183
|
id_token = tokens.get("id_token")
|
|
@@ -214,8 +238,7 @@ def create_auth_router(
|
|
|
214
238
|
email = require_user(request)
|
|
215
239
|
if not email:
|
|
216
240
|
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
|
|
217
|
-
|
|
218
|
-
raise HTTPException(status_code=400, detail="새 비밀번호는 4자 이상이어야 합니다.")
|
|
241
|
+
_enforce_password_policy(req.new_password)
|
|
219
242
|
users = load_users()
|
|
220
243
|
user = users.get(email)
|
|
221
244
|
if not user:
|
package/latticeai/api/chat.py
CHANGED
|
@@ -25,9 +25,8 @@ from latticeai.core.agent import AgentRunContext, AgentState
|
|
|
25
25
|
from latticeai.core.context_builder import format_sources_footnote, retrieve_context_for_generation
|
|
26
26
|
from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
|
|
27
27
|
from latticeai.core.hooks import dispatch_tool
|
|
28
|
-
from latticeai.services.
|
|
28
|
+
from latticeai.services.app_context import AppContext
|
|
29
29
|
from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
|
|
30
|
-
from telegram_bot import broadcast_web_chat
|
|
31
30
|
from tools import AGENT_ROOT, ToolError, ensure_agent_root, execute_tool, knowledge_save, local_read, network_status
|
|
32
31
|
|
|
33
32
|
class ChatRequest(BaseModel):
|
|
@@ -71,6 +70,28 @@ class AgentEvalRequest(BaseModel):
|
|
|
71
70
|
skill: str
|
|
72
71
|
case_id: Optional[str] = None
|
|
73
72
|
|
|
73
|
+
def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
|
|
74
|
+
"""Restrict history to one user's exchange.
|
|
75
|
+
|
|
76
|
+
Keeps the user's own messages plus assistant replies that directly follow
|
|
77
|
+
them. A bare role=="assistant" pass would leak every other user's replies
|
|
78
|
+
into this user's prompt context.
|
|
79
|
+
"""
|
|
80
|
+
paired: List[Dict] = []
|
|
81
|
+
include_next_assistant = False
|
|
82
|
+
for item in history:
|
|
83
|
+
if item.get("role") == "assistant":
|
|
84
|
+
if include_next_assistant:
|
|
85
|
+
paired.append(item)
|
|
86
|
+
include_next_assistant = False
|
|
87
|
+
elif item.get("user_email") == user_email:
|
|
88
|
+
paired.append(item)
|
|
89
|
+
include_next_assistant = True
|
|
90
|
+
else:
|
|
91
|
+
include_next_assistant = False
|
|
92
|
+
return paired
|
|
93
|
+
|
|
94
|
+
|
|
74
95
|
def detect_language(text: str) -> str:
|
|
75
96
|
"""Detect language: 'ko' (Korean) or 'en' (English)."""
|
|
76
97
|
total = max(len(text), 1)
|
|
@@ -120,46 +141,57 @@ async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[
|
|
|
120
141
|
yield "data: [DONE]\n\n"
|
|
121
142
|
|
|
122
143
|
|
|
123
|
-
def create_chat_router(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
gardener,
|
|
131
|
-
require_user,
|
|
132
|
-
enforce_rate_limit,
|
|
133
|
-
get_history_user,
|
|
134
|
-
save_to_history,
|
|
135
|
-
append_audit_event,
|
|
136
|
-
clear_history,
|
|
137
|
-
clear_conversation,
|
|
138
|
-
get_history,
|
|
139
|
-
group_history_conversations,
|
|
140
|
-
get_conversation_messages,
|
|
141
|
-
conversation_title,
|
|
142
|
-
load_users,
|
|
143
|
-
get_user_role,
|
|
144
|
-
enable_graph: bool,
|
|
145
|
-
knowledge_graph,
|
|
146
|
-
public_model: str,
|
|
147
|
-
base_dir: Path,
|
|
148
|
-
hooks=None,
|
|
149
|
-
) -> APIRouter:
|
|
144
|
+
def create_chat_router(context: AppContext) -> APIRouter:
|
|
145
|
+
"""Build the chat/history/agent router from the typed application context.
|
|
146
|
+
|
|
147
|
+
Replaces the historical ~25-kwarg factory signature: ``context``
|
|
148
|
+
(:class:`latticeai.services.app_context.AppContext`) carries the same
|
|
149
|
+
dependencies as typed fields.
|
|
150
|
+
"""
|
|
150
151
|
api_router = APIRouter()
|
|
151
|
-
router = model_router
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
152
|
+
router = context.model_router
|
|
153
|
+
hooks = context.hooks
|
|
154
|
+
workspace_graph = context.workspace_graph
|
|
155
|
+
context_assembler = context.context_assembler
|
|
156
|
+
require_user = context.require_user
|
|
157
|
+
enforce_rate_limit = context.enforce_rate_limit
|
|
158
|
+
get_history_user = context.get_history_user
|
|
159
|
+
save_to_history = context.save_to_history
|
|
160
|
+
append_audit_event = context.append_audit_event
|
|
161
|
+
clear_history = context.clear_history
|
|
162
|
+
clear_conversation = context.clear_conversation
|
|
163
|
+
get_history = context.get_history
|
|
164
|
+
group_history_conversations = context.group_history_conversations
|
|
165
|
+
get_conversation_messages = context.get_conversation_messages
|
|
166
|
+
conversation_title = context.conversation_title
|
|
167
|
+
|
|
168
|
+
CONFIG = context.config
|
|
169
|
+
CHAT_SERVICE = context.chat_service
|
|
170
|
+
WORKSPACE_OS = context.workspace_store
|
|
171
|
+
ENABLE_GRAPH = context.enable_graph
|
|
172
|
+
KNOWLEDGE_GRAPH = context.knowledge_graph
|
|
173
|
+
PUBLIC_MODEL = context.public_model
|
|
174
|
+
BASE_DIR = context.base_dir
|
|
159
175
|
_doc_gen_sessions: dict = {}
|
|
160
176
|
_pending_agents: dict[str, tuple] = {}
|
|
161
177
|
_pending_agents_lock = threading.Lock()
|
|
162
178
|
|
|
179
|
+
on_chat_message = context.on_chat_message
|
|
180
|
+
|
|
181
|
+
def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
|
|
182
|
+
"""Mirror a chat exchange to the injected external bridge, if any.
|
|
183
|
+
|
|
184
|
+
``on_chat_message`` is registered by ``create_app`` only when
|
|
185
|
+
ENABLE_TELEGRAM is truthy; exchanges that originated *from* telegram
|
|
186
|
+
are never echoed back. No bridge registered → no-op.
|
|
187
|
+
"""
|
|
188
|
+
if on_chat_message is None or source == "telegram":
|
|
189
|
+
return
|
|
190
|
+
try:
|
|
191
|
+
on_chat_message(role, text, source)
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
logging.warning("chat message bridge failed: %s", exc)
|
|
194
|
+
|
|
163
195
|
def build_recent_chat_context(
|
|
164
196
|
limit: int = 10,
|
|
165
197
|
include_image_missing_replies: bool = True,
|
|
@@ -170,7 +202,7 @@ def create_chat_router(
|
|
|
170
202
|
if conversation_id:
|
|
171
203
|
history = [item for item in history if item.get("conversation_id") == conversation_id]
|
|
172
204
|
if user_email:
|
|
173
|
-
history =
|
|
205
|
+
history = pair_user_history(history, user_email)
|
|
174
206
|
history = history[-limit:]
|
|
175
207
|
lines = []
|
|
176
208
|
for item in history:
|
|
@@ -250,6 +282,7 @@ def create_chat_router(
|
|
|
250
282
|
knowledge_save=knowledge_save,
|
|
251
283
|
audit=append_audit_event,
|
|
252
284
|
hooks=hooks,
|
|
285
|
+
brain_memory=context.brain_memory,
|
|
253
286
|
)
|
|
254
287
|
|
|
255
288
|
@api_router.post("/chat")
|
|
@@ -272,9 +305,8 @@ def create_chat_router(
|
|
|
272
305
|
except ToolError as exc:
|
|
273
306
|
answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
|
|
274
307
|
save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
asyncio.create_task(broadcast_web_chat("assistant", answer))
|
|
308
|
+
notify_chat_message("user", req.message, req.source)
|
|
309
|
+
notify_chat_message("assistant", answer, req.source)
|
|
278
310
|
if req.stream:
|
|
279
311
|
return StreamingResponse(
|
|
280
312
|
single_text_stream(answer),
|
|
@@ -332,9 +364,8 @@ def create_chat_router(
|
|
|
332
364
|
answer = f"현재 페이지 URL: {req.client_url}"
|
|
333
365
|
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
334
366
|
save_to_history("assistant", answer, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
asyncio.create_task(broadcast_web_chat("assistant", answer))
|
|
367
|
+
notify_chat_message("user", req.message, req.source)
|
|
368
|
+
notify_chat_message("assistant", answer, req.source)
|
|
338
369
|
if req.stream:
|
|
339
370
|
return StreamingResponse(
|
|
340
371
|
single_text_stream(answer),
|
|
@@ -364,32 +395,38 @@ def create_chat_router(
|
|
|
364
395
|
|
|
365
396
|
lang = detect_language(req.message)
|
|
366
397
|
context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
|
|
398
|
+
# v4 Context System: one budgeted, provenance-carrying assembly
|
|
399
|
+
# (workspace memories + hybrid search + garden notes) replaces the
|
|
400
|
+
# ad-hoc vault-scan + LIKE-search concatenation. The trace records
|
|
401
|
+
# why each section is in the prompt.
|
|
402
|
+
context_trace = None
|
|
367
403
|
try:
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
404
|
+
if context_assembler is not None:
|
|
405
|
+
assembled = context_assembler.assemble(
|
|
406
|
+
req.message,
|
|
407
|
+
user_email=effective_email,
|
|
408
|
+
conversation_id=req.conversation_id,
|
|
409
|
+
budget=2000,
|
|
410
|
+
)
|
|
411
|
+
context_trace = assembled.trace()
|
|
412
|
+
if assembled.text:
|
|
413
|
+
context += "\n\n" + assembled.text
|
|
372
414
|
except Exception as e:
|
|
373
|
-
logging.warning("
|
|
374
|
-
|
|
415
|
+
logging.warning("Context assembly skipped: %s", e)
|
|
416
|
+
|
|
375
417
|
is_doc_gen = detect_document_intent(req.message)
|
|
376
418
|
doc_gen_context_result = None
|
|
377
|
-
|
|
419
|
+
|
|
378
420
|
try:
|
|
379
|
-
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
else:
|
|
389
|
-
graph_context = KNOWLEDGE_GRAPH.context_for_query(req.message)
|
|
390
|
-
if graph_context:
|
|
391
|
-
context += f"\n\n[KNOWLEDGE GRAPH]\n{graph_context}"
|
|
392
|
-
print("🕸️ Context reinforced with knowledge graph.")
|
|
421
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH and is_doc_gen:
|
|
422
|
+
# Specialized multi-hop retrieval for document generation.
|
|
423
|
+
doc_gen_context_result = retrieve_context_for_generation(
|
|
424
|
+
KNOWLEDGE_GRAPH, req.message, max_results=10, max_hops=2,
|
|
425
|
+
)
|
|
426
|
+
graph_md = doc_gen_context_result.get("context_markdown", "")
|
|
427
|
+
if graph_md:
|
|
428
|
+
context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
|
|
429
|
+
print("📝 Document generation context retrieved from knowledge graph.")
|
|
393
430
|
except Exception as e:
|
|
394
431
|
logging.warning("Knowledge graph reinforcement skipped: %s", e)
|
|
395
432
|
|
|
@@ -416,11 +453,14 @@ def create_chat_router(
|
|
|
416
453
|
KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
|
|
417
454
|
context,
|
|
418
455
|
)
|
|
456
|
+
if context_trace is not None and isinstance(trace_seed, dict):
|
|
457
|
+
# Persisted with the answer trace: 'why is this in my context?'
|
|
458
|
+
# is answerable from the stored record (UI surface lands in T9b).
|
|
459
|
+
trace_seed["context_assembly"] = context_trace
|
|
419
460
|
|
|
420
461
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
421
462
|
save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
|
|
422
|
-
|
|
423
|
-
asyncio.create_task(broadcast_web_chat("user", req.message))
|
|
463
|
+
notify_chat_message("user", req.message, req.source)
|
|
424
464
|
|
|
425
465
|
if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
426
466
|
conv_key = req.conversation_id or "default"
|
|
@@ -456,8 +496,7 @@ def create_chat_router(
|
|
|
456
496
|
user_email=effective_email,
|
|
457
497
|
trace=trace_seed,
|
|
458
498
|
)
|
|
459
|
-
|
|
460
|
-
asyncio.create_task(broadcast_web_chat("assistant", full_text))
|
|
499
|
+
notify_chat_message("assistant", full_text, req.source)
|
|
461
500
|
yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
|
|
462
501
|
yield "data: [DONE]\n\n"
|
|
463
502
|
return StreamingResponse(
|
|
@@ -482,8 +521,7 @@ def create_chat_router(
|
|
|
482
521
|
user_email=effective_email,
|
|
483
522
|
trace=trace_seed,
|
|
484
523
|
)
|
|
485
|
-
|
|
486
|
-
asyncio.create_task(broadcast_web_chat("assistant", str(result)))
|
|
524
|
+
notify_chat_message("assistant", str(result), req.source)
|
|
487
525
|
return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
|
|
488
526
|
|
|
489
527
|
if req.stream:
|
|
@@ -519,8 +557,7 @@ def create_chat_router(
|
|
|
519
557
|
user_email=effective_email,
|
|
520
558
|
trace=trace_seed,
|
|
521
559
|
)
|
|
522
|
-
|
|
523
|
-
asyncio.create_task(broadcast_web_chat("assistant", str(result)))
|
|
560
|
+
notify_chat_message("assistant", str(result), req.source)
|
|
524
561
|
|
|
525
562
|
return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
|
|
526
563
|
|
|
@@ -627,8 +664,7 @@ def create_chat_router(
|
|
|
627
664
|
context,
|
|
628
665
|
),
|
|
629
666
|
)
|
|
630
|
-
|
|
631
|
-
asyncio.create_task(broadcast_web_chat("assistant", full_response))
|
|
667
|
+
notify_chat_message("assistant", full_response, req.source)
|
|
632
668
|
yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
|
|
633
669
|
yield "data: [DONE]\n\n"
|
|
634
670
|
|
|
@@ -751,7 +787,7 @@ def create_chat_router(
|
|
|
751
787
|
user_email=current_user or None,
|
|
752
788
|
timeline=ctx.transcript,
|
|
753
789
|
relationships=["agent:planner", "agent:reviewer"],
|
|
754
|
-
graph=
|
|
790
|
+
graph=workspace_graph(),
|
|
755
791
|
)
|
|
756
792
|
except Exception as exc:
|
|
757
793
|
logging.warning("workspace agent run record failed: %s", exc)
|
package/latticeai/api/health.py
CHANGED
|
@@ -8,7 +8,7 @@ endpoints (install / verify-cloud / pull-model) stay in server_app for now.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
|
-
from typing import
|
|
11
|
+
from typing import Callable, List, Optional
|
|
12
12
|
|
|
13
13
|
from fastapi import APIRouter, Request
|
|
14
14
|
|
package/latticeai/api/hooks.py
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Knowledge graph page and API routes.
|
|
2
|
+
|
|
3
|
+
Relocated from the root ``knowledge_graph_api.py`` in v4 (T2); the root module
|
|
4
|
+
remains as a deprecation shim. Route paths and response shapes are frozen by
|
|
5
|
+
``tests/unit/test_knowledge_graph_router_parity.py`` — the ``/knowledge-graph/*``
|
|
6
|
+
data endpoints back the v3 SPA (Files, Hybrid Search, graph explorer).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
13
|
+
from fastapi.responses import FileResponse
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class KnowledgeGraphIngestRequest(BaseModel):
|
|
18
|
+
type: str
|
|
19
|
+
content: str = ""
|
|
20
|
+
role: Optional[str] = None
|
|
21
|
+
title: Optional[str] = None
|
|
22
|
+
source: Optional[str] = None
|
|
23
|
+
conversation_id: Optional[str] = None
|
|
24
|
+
user_email: Optional[str] = None
|
|
25
|
+
user_nickname: Optional[str] = None
|
|
26
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_knowledge_graph_router(
|
|
30
|
+
*,
|
|
31
|
+
get_graph: Callable[[], Any],
|
|
32
|
+
require_graph: Callable[[], None],
|
|
33
|
+
require_user: Callable[[Request], str],
|
|
34
|
+
static_dir: Path,
|
|
35
|
+
) -> APIRouter:
|
|
36
|
+
router = APIRouter()
|
|
37
|
+
|
|
38
|
+
def graph():
|
|
39
|
+
require_graph()
|
|
40
|
+
return get_graph()
|
|
41
|
+
|
|
42
|
+
@router.get("/graph")
|
|
43
|
+
async def knowledge_graph_page(request: Request):
|
|
44
|
+
"""Serve the interactive knowledge graph canvas UI."""
|
|
45
|
+
graph()
|
|
46
|
+
require_user(request)
|
|
47
|
+
response = FileResponse(static_dir / "graph.html")
|
|
48
|
+
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
49
|
+
response.headers["Pragma"] = "no-cache"
|
|
50
|
+
response.headers["Expires"] = "0"
|
|
51
|
+
return response
|
|
52
|
+
|
|
53
|
+
@router.get("/knowledge-graph")
|
|
54
|
+
async def knowledge_graph_legacy_page(request: Request):
|
|
55
|
+
"""Backward-compatible route for the graph page."""
|
|
56
|
+
graph()
|
|
57
|
+
require_user(request)
|
|
58
|
+
response = FileResponse(static_dir / "graph.html")
|
|
59
|
+
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
60
|
+
response.headers["Pragma"] = "no-cache"
|
|
61
|
+
response.headers["Expires"] = "0"
|
|
62
|
+
return response
|
|
63
|
+
|
|
64
|
+
@router.post("/knowledge-graph/curate")
|
|
65
|
+
async def knowledge_graph_curate(request: Request):
|
|
66
|
+
require_user(request)
|
|
67
|
+
return graph().curate()
|
|
68
|
+
|
|
69
|
+
@router.get("/knowledge-graph/provenance/coverage")
|
|
70
|
+
async def knowledge_graph_provenance_coverage(request: Request):
|
|
71
|
+
require_user(request)
|
|
72
|
+
return graph().provenance_coverage()
|
|
73
|
+
|
|
74
|
+
@router.get("/knowledge-graph/stats")
|
|
75
|
+
async def knowledge_graph_stats(request: Request):
|
|
76
|
+
require_user(request)
|
|
77
|
+
return graph().stats()
|
|
78
|
+
|
|
79
|
+
@router.get("/knowledge-graph/schema")
|
|
80
|
+
async def knowledge_graph_schema(request: Request):
|
|
81
|
+
require_user(request)
|
|
82
|
+
stats = graph().stats()
|
|
83
|
+
return {
|
|
84
|
+
"legacy_schema_version": stats.get("schema_version"),
|
|
85
|
+
"v2_schema_available": stats.get("v2_schema_available"),
|
|
86
|
+
"v2": stats.get("v2"),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@router.get("/knowledge-graph/graph")
|
|
90
|
+
async def knowledge_graph_data(request: Request, limit: int = 300):
|
|
91
|
+
require_user(request)
|
|
92
|
+
return graph().graph(limit)
|
|
93
|
+
|
|
94
|
+
@router.get("/knowledge-graph/documents")
|
|
95
|
+
async def knowledge_graph_documents(request: Request, limit: int = 200):
|
|
96
|
+
"""Ingested documents (uploads + indexed local docs) with index state.
|
|
97
|
+
|
|
98
|
+
Backs the Files view so uploaded content is visible end-to-end:
|
|
99
|
+
upload → Files → Knowledge Graph → Hybrid Search → Chat.
|
|
100
|
+
"""
|
|
101
|
+
require_user(request)
|
|
102
|
+
return graph().list_documents(limit)
|
|
103
|
+
|
|
104
|
+
@router.get("/knowledge-graph/search")
|
|
105
|
+
async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
|
|
106
|
+
require_user(request)
|
|
107
|
+
if not q or not q.strip():
|
|
108
|
+
return {"query": q, "matches": []}
|
|
109
|
+
return graph().search(q, limit)
|
|
110
|
+
|
|
111
|
+
@router.get("/knowledge-graph/context")
|
|
112
|
+
async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
|
|
113
|
+
require_user(request)
|
|
114
|
+
return {"query": q, "context": graph().context_for_query(q, limit)}
|
|
115
|
+
|
|
116
|
+
@router.get("/knowledge-graph/neighbors/{node_id:path}")
|
|
117
|
+
async def knowledge_graph_neighbors(node_id: str, request: Request):
|
|
118
|
+
require_user(request)
|
|
119
|
+
if not node_id:
|
|
120
|
+
raise HTTPException(status_code=400, detail="node_id required")
|
|
121
|
+
return graph().neighbors(node_id)
|
|
122
|
+
|
|
123
|
+
@router.post("/knowledge-graph/ingest")
|
|
124
|
+
async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
|
|
125
|
+
current_user = require_user(request)
|
|
126
|
+
kg = graph()
|
|
127
|
+
event_type = (req.type or "").strip().lower()
|
|
128
|
+
if event_type not in {"message", "ai_response", "note"}:
|
|
129
|
+
raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
|
|
130
|
+
role = req.role or ("assistant" if event_type == "ai_response" else "user")
|
|
131
|
+
return kg.ingest_message(
|
|
132
|
+
role,
|
|
133
|
+
req.content,
|
|
134
|
+
user_email=req.user_email or current_user,
|
|
135
|
+
user_nickname=req.user_nickname,
|
|
136
|
+
source=req.source or "mcp",
|
|
137
|
+
conversation_id=req.conversation_id,
|
|
138
|
+
raw={
|
|
139
|
+
"type": req.type,
|
|
140
|
+
"title": req.title,
|
|
141
|
+
"content": req.content,
|
|
142
|
+
"metadata": req.metadata or {},
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return router
|
|
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
14
14
|
from fastapi.responses import FileResponse
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
|
-
from
|
|
17
|
+
from latticeai.api.knowledge_graph import create_knowledge_graph_router
|
|
18
18
|
from local_knowledge_api import create_local_knowledge_router
|
|
19
19
|
from tools import local_list, local_read, local_write
|
|
20
20
|
|
package/latticeai/api/mcp.py
CHANGED
|
@@ -17,11 +17,12 @@ from datetime import datetime
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import Any, Callable, Dict, List, Optional
|
|
19
19
|
|
|
20
|
+
from latticeai.services.ingestion import IngestionItem
|
|
20
21
|
from fastapi import APIRouter, HTTPException, Request
|
|
21
22
|
from pydantic import BaseModel
|
|
22
23
|
|
|
23
|
-
import mcp_registry
|
|
24
|
-
from mcp_registry import (
|
|
24
|
+
import latticeai.core.mcp_registry as mcp_registry
|
|
25
|
+
from latticeai.core.mcp_registry import (
|
|
25
26
|
_get_combined_registry,
|
|
26
27
|
_fetch_skills_marketplace,
|
|
27
28
|
_fetch_plugin_directory,
|
|
@@ -76,6 +77,7 @@ def create_mcp_router(
|
|
|
76
77
|
tool_response: Callable[..., Any],
|
|
77
78
|
require_graph: Callable[[], Any],
|
|
78
79
|
knowledge_graph: Any,
|
|
80
|
+
ingestion_pipeline: Any,
|
|
79
81
|
data_dir: Path,
|
|
80
82
|
) -> APIRouter:
|
|
81
83
|
router = APIRouter()
|
|
@@ -290,7 +292,7 @@ def create_mcp_router(
|
|
|
290
292
|
if not skill_md.exists():
|
|
291
293
|
continue
|
|
292
294
|
lines = skill_md.read_text(encoding="utf-8").splitlines()
|
|
293
|
-
desc = next((
|
|
295
|
+
desc = next((ln.split(":", 1)[1].strip() for ln in lines if ln.startswith("description:")), "")
|
|
294
296
|
comment = lines[0] if lines else ""
|
|
295
297
|
if "anthropics/claude-plugins-official" in comment:
|
|
296
298
|
source = "anthropic"
|
|
@@ -357,15 +359,25 @@ def create_mcp_router(
|
|
|
357
359
|
args = req.args or {}
|
|
358
360
|
if req.action == "knowledge_graph_ingest":
|
|
359
361
|
_require_graph()
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
362
|
+
# v4: MCP messages enter the brain through the unified ingestion
|
|
363
|
+
# pipeline (provenance + hook lifecycle), not a direct store call.
|
|
364
|
+
owner = args.get("user_email") or current_user
|
|
365
|
+
result = ingestion_pipeline.ingest(
|
|
366
|
+
IngestionItem(
|
|
367
|
+
source_type="mcp_message",
|
|
368
|
+
text=args.get("content") or "",
|
|
369
|
+
owner=owner,
|
|
370
|
+
conversation_id=args.get("conversation_id"),
|
|
371
|
+
metadata={
|
|
372
|
+
"role": args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
|
|
373
|
+
"user_nickname": args.get("user_nickname"),
|
|
374
|
+
"source": args.get("source") or "mcp",
|
|
375
|
+
"raw": args,
|
|
376
|
+
},
|
|
377
|
+
),
|
|
378
|
+
user_email=owner,
|
|
368
379
|
)
|
|
380
|
+
return result.as_dict()
|
|
369
381
|
if req.action == "knowledge_graph_search":
|
|
370
382
|
_require_graph()
|
|
371
383
|
return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
|
package/latticeai/api/memory.py
CHANGED
|
@@ -7,7 +7,7 @@ operations (prune / compact / rebuild / clear). Full paths in decorators.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from typing import
|
|
10
|
+
from typing import Callable, List, Optional
|
|
11
11
|
|
|
12
12
|
from fastapi import APIRouter, HTTPException, Request
|
|
13
13
|
from pydantic import BaseModel
|