ltcai 9.0.0 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -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 +146 -2
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -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 +24 -1
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +302 -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 +19 -9
- 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/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-Bzz0bUyW.js +1 -0
- package/static/app/assets/Brain-Dj2J20YA.js +321 -0
- package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
- package/static/app/assets/Library-B03FP1Yx.js +1 -0
- package/static/app/assets/System-80lHW0Ux.js +1 -0
- package/static/app/assets/index-BiMofBTM.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-Q1A96_7v.js +1 -0
- package/static/app/assets/textarea-D13RtnTo.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
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""HTTP adapter for the local chat agent runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
16
|
+
|
|
17
|
+
from lattice_brain.runtime.hooks import dispatch_tool
|
|
18
|
+
from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
|
|
19
|
+
from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
|
|
20
|
+
from latticeai.core.agent import AgentRunContext, AgentState
|
|
21
|
+
from latticeai.services.tool_dispatch import collect_created_files
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AgentHTTPController:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
runtime: Any,
|
|
29
|
+
model_router: Any,
|
|
30
|
+
require_user: Any,
|
|
31
|
+
require_admin: Any,
|
|
32
|
+
enforce_rate_limit: Any,
|
|
33
|
+
authenticated_identity: Any,
|
|
34
|
+
write_workspace: Any,
|
|
35
|
+
save_to_history: Any,
|
|
36
|
+
workspace_store: Any,
|
|
37
|
+
workspace_graph: Any,
|
|
38
|
+
hooks: Any,
|
|
39
|
+
execute_tool: Any,
|
|
40
|
+
base_dir: Path,
|
|
41
|
+
agent_root: Path,
|
|
42
|
+
ensure_agent_root: Any,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.runtime = runtime
|
|
45
|
+
self.model_router = model_router
|
|
46
|
+
self.require_user = require_user
|
|
47
|
+
self.require_admin = require_admin
|
|
48
|
+
self.enforce_rate_limit = enforce_rate_limit
|
|
49
|
+
self.authenticated_identity = authenticated_identity
|
|
50
|
+
self.write_workspace = write_workspace
|
|
51
|
+
self.save_to_history = save_to_history
|
|
52
|
+
self.workspace_store = workspace_store
|
|
53
|
+
self.workspace_graph = workspace_graph
|
|
54
|
+
self.hooks = hooks
|
|
55
|
+
self.execute_tool = execute_tool
|
|
56
|
+
self.base_dir = Path(base_dir)
|
|
57
|
+
self.agent_root = Path(agent_root)
|
|
58
|
+
self.ensure_agent_root = ensure_agent_root
|
|
59
|
+
self._pending: Dict[str, tuple] = {}
|
|
60
|
+
self._pending_lock = threading.Lock()
|
|
61
|
+
self._pending_ttl_seconds = 15 * 60
|
|
62
|
+
self._background_tasks: set[asyncio.Task] = set()
|
|
63
|
+
|
|
64
|
+
def register_routes(self, router: APIRouter) -> None:
|
|
65
|
+
@router.post("/agent/eval")
|
|
66
|
+
async def agent_eval(req: AgentEvalRequest, request: Request):
|
|
67
|
+
return await self.eval(req, request)
|
|
68
|
+
|
|
69
|
+
@router.post("/agent")
|
|
70
|
+
async def agent(req: AgentRequest, request: Request):
|
|
71
|
+
return await self.agent(req, request)
|
|
72
|
+
|
|
73
|
+
@router.post("/agent/resume")
|
|
74
|
+
async def agent_resume(req: AgentResumeRequest, request: Request):
|
|
75
|
+
return await self.resume(req, request)
|
|
76
|
+
|
|
77
|
+
def _schedule_background_task(self, coro: Any) -> None:
|
|
78
|
+
task = asyncio.create_task(coro)
|
|
79
|
+
self._background_tasks.add(task)
|
|
80
|
+
|
|
81
|
+
def finish(done: asyncio.Task) -> None:
|
|
82
|
+
self._background_tasks.discard(done)
|
|
83
|
+
try:
|
|
84
|
+
done.result()
|
|
85
|
+
except asyncio.CancelledError:
|
|
86
|
+
pass
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
logging.warning("background chat task failed: %s", exc)
|
|
89
|
+
|
|
90
|
+
task.add_done_callback(finish)
|
|
91
|
+
|
|
92
|
+
async def eval(self, req: AgentEvalRequest, request: Request) -> Dict[str, Any]:
|
|
93
|
+
"""Run a skill's schema.json eval cases."""
|
|
94
|
+
if self.require_admin is not None:
|
|
95
|
+
self.require_admin(request)
|
|
96
|
+
else:
|
|
97
|
+
self.require_user(request)
|
|
98
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", req.skill):
|
|
99
|
+
raise HTTPException(status_code=400, detail="Invalid skill name.")
|
|
100
|
+
skills_root = (self.base_dir / "skills").resolve()
|
|
101
|
+
skill_dir = (skills_root / req.skill).resolve()
|
|
102
|
+
if skill_dir.parent != skills_root:
|
|
103
|
+
raise HTTPException(status_code=400, detail="Invalid skill path.")
|
|
104
|
+
schema_path = skill_dir / "schema.json"
|
|
105
|
+
if not schema_path.exists():
|
|
106
|
+
raise HTTPException(
|
|
107
|
+
status_code=404,
|
|
108
|
+
detail=f"Skill '{req.skill}' not found or missing schema.json",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
112
|
+
eval_cases = schema.get("evals", [])
|
|
113
|
+
if req.case_id:
|
|
114
|
+
eval_cases = [case for case in eval_cases if case.get("id") == req.case_id]
|
|
115
|
+
if not eval_cases:
|
|
116
|
+
return {
|
|
117
|
+
"skill": req.skill,
|
|
118
|
+
"total": 0,
|
|
119
|
+
"passed": 0,
|
|
120
|
+
"failed": 0,
|
|
121
|
+
"results": [],
|
|
122
|
+
"message": "No eval cases defined in schema.json",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
action_name = schema.get("action", req.skill)
|
|
126
|
+
results = []
|
|
127
|
+
for case in eval_cases:
|
|
128
|
+
case_id = case.get("id", "?")
|
|
129
|
+
try:
|
|
130
|
+
case_input = case.get("input", {})
|
|
131
|
+
result = dispatch_tool(
|
|
132
|
+
self.hooks,
|
|
133
|
+
action_name,
|
|
134
|
+
case_input,
|
|
135
|
+
lambda: self.execute_tool(action_name, case_input),
|
|
136
|
+
source="eval",
|
|
137
|
+
)
|
|
138
|
+
criteria = case.get("pass_criteria", "")
|
|
139
|
+
if "success == true" in criteria:
|
|
140
|
+
passed = result.get("success") is True
|
|
141
|
+
elif "success == false" in criteria:
|
|
142
|
+
passed = result.get("success") is False
|
|
143
|
+
else:
|
|
144
|
+
passed = True
|
|
145
|
+
results.append(
|
|
146
|
+
{
|
|
147
|
+
"id": case_id,
|
|
148
|
+
"description": case.get("description", ""),
|
|
149
|
+
"passed": passed,
|
|
150
|
+
"result": result,
|
|
151
|
+
"pass_criteria": criteria,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
results.append(
|
|
156
|
+
{
|
|
157
|
+
"id": case_id,
|
|
158
|
+
"description": case.get("description", ""),
|
|
159
|
+
"passed": False,
|
|
160
|
+
"error": str(exc),
|
|
161
|
+
"pass_criteria": case.get("pass_criteria", ""),
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
passed_count = sum(1 for result in results if result.get("passed") is True)
|
|
165
|
+
return {
|
|
166
|
+
"skill": req.skill,
|
|
167
|
+
"action": action_name,
|
|
168
|
+
"total": len(results),
|
|
169
|
+
"passed": passed_count,
|
|
170
|
+
"failed": len(results) - passed_count,
|
|
171
|
+
"results": results,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async def agent(self, req: AgentRequest, request: Request) -> Dict[str, Any]:
|
|
175
|
+
"""Plan and execute a natural-language local agent run."""
|
|
176
|
+
current_user = self.require_user(request)
|
|
177
|
+
self.enforce_rate_limit(current_user, "agent")
|
|
178
|
+
effective_email = self.authenticated_identity(current_user, req.user_email)
|
|
179
|
+
header_workspace = workspace_scope_from_request(request)
|
|
180
|
+
if req.workspace_id and header_workspace and req.workspace_id != header_workspace:
|
|
181
|
+
raise HTTPException(
|
|
182
|
+
status_code=403,
|
|
183
|
+
detail="workspace_id must match X-Workspace-Id.",
|
|
184
|
+
)
|
|
185
|
+
req.workspace_id = self.write_workspace(
|
|
186
|
+
req.workspace_id or header_workspace,
|
|
187
|
+
current_user,
|
|
188
|
+
)
|
|
189
|
+
req.user_email = effective_email
|
|
190
|
+
if not self.model_router.current_model_id:
|
|
191
|
+
raise HTTPException(
|
|
192
|
+
status_code=400,
|
|
193
|
+
detail="No model loaded. Call /models/load first.",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
self.ensure_agent_root()
|
|
197
|
+
language_hint = _LANG_HINT[detect_language(req.message)]
|
|
198
|
+
max_steps = max(1, min(req.max_steps, 50))
|
|
199
|
+
max_retry = 3
|
|
200
|
+
ctx = AgentRunContext()
|
|
201
|
+
ctx.executing_model = req.executing_model
|
|
202
|
+
ctx.reviewing_model = req.reviewing_model
|
|
203
|
+
ctx.state = AgentState.PLANNING
|
|
204
|
+
ctx.state_history.append(ctx.state.value)
|
|
205
|
+
await self.runtime.plan(
|
|
206
|
+
ctx,
|
|
207
|
+
req,
|
|
208
|
+
language_hint,
|
|
209
|
+
current_user,
|
|
210
|
+
model_id=req.planning_model,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if req.human_in_loop:
|
|
214
|
+
context_id = secrets.token_urlsafe(16)
|
|
215
|
+
with self._pending_lock:
|
|
216
|
+
self._pending[context_id] = (
|
|
217
|
+
ctx,
|
|
218
|
+
req,
|
|
219
|
+
language_hint,
|
|
220
|
+
current_user,
|
|
221
|
+
time.monotonic(),
|
|
222
|
+
)
|
|
223
|
+
return {
|
|
224
|
+
"status": "waiting_approval",
|
|
225
|
+
"context_id": context_id,
|
|
226
|
+
"plan": ctx.plan,
|
|
227
|
+
"steps": ctx.transcript,
|
|
228
|
+
"state_history": ctx.state_history,
|
|
229
|
+
"planning_model": req.planning_model or self.model_router.current_model_id,
|
|
230
|
+
"executing_model": req.executing_model or self.model_router.current_model_id,
|
|
231
|
+
"reviewing_model": req.reviewing_model or self.model_router.current_model_id,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
self.runtime.approve(ctx, current_user, approved_by_human=False)
|
|
235
|
+
return await self._finish(
|
|
236
|
+
ctx,
|
|
237
|
+
req,
|
|
238
|
+
language_hint,
|
|
239
|
+
current_user,
|
|
240
|
+
max_steps,
|
|
241
|
+
max_retry,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def _finish(
|
|
245
|
+
self,
|
|
246
|
+
ctx: AgentRunContext,
|
|
247
|
+
req: AgentRequest,
|
|
248
|
+
language_hint: str,
|
|
249
|
+
current_user: str,
|
|
250
|
+
max_steps: int,
|
|
251
|
+
max_retry: int,
|
|
252
|
+
) -> Dict[str, Any]:
|
|
253
|
+
await self.runtime.run_to_completion(
|
|
254
|
+
ctx,
|
|
255
|
+
req,
|
|
256
|
+
language_hint,
|
|
257
|
+
current_user,
|
|
258
|
+
max_steps,
|
|
259
|
+
max_retry,
|
|
260
|
+
)
|
|
261
|
+
self._schedule_background_task(self.runtime.memory_update(ctx, req, current_user))
|
|
262
|
+
|
|
263
|
+
message = ctx.final_message or "작업을 완료했습니다."
|
|
264
|
+
history_user = {
|
|
265
|
+
"user_email": req.user_email or current_user or None,
|
|
266
|
+
"user_nickname": req.user_nickname,
|
|
267
|
+
}
|
|
268
|
+
await asyncio.to_thread(
|
|
269
|
+
self.save_to_history,
|
|
270
|
+
"user",
|
|
271
|
+
req.message,
|
|
272
|
+
**history_user,
|
|
273
|
+
source=req.source or "web",
|
|
274
|
+
conversation_id=req.conversation_id,
|
|
275
|
+
workspace_id=req.workspace_id,
|
|
276
|
+
)
|
|
277
|
+
await asyncio.to_thread(
|
|
278
|
+
self.save_to_history,
|
|
279
|
+
"assistant",
|
|
280
|
+
message,
|
|
281
|
+
**history_user,
|
|
282
|
+
source=req.source or "web",
|
|
283
|
+
conversation_id=req.conversation_id,
|
|
284
|
+
workspace_id=req.workspace_id,
|
|
285
|
+
)
|
|
286
|
+
try:
|
|
287
|
+
self.workspace_store.record_agent_run(
|
|
288
|
+
agent_id="agent:executor",
|
|
289
|
+
status="ok" if ctx.state == AgentState.DONE else "failed",
|
|
290
|
+
input_text=req.message,
|
|
291
|
+
output_text=message,
|
|
292
|
+
user_email=current_user or None,
|
|
293
|
+
workspace_id=req.workspace_id,
|
|
294
|
+
mode="llm",
|
|
295
|
+
timeline=ctx.transcript,
|
|
296
|
+
relationships=["agent:planner", "agent:reviewer"],
|
|
297
|
+
graph=self.workspace_graph(),
|
|
298
|
+
)
|
|
299
|
+
except Exception as exc:
|
|
300
|
+
logging.warning("workspace agent run record failed: %s", exc)
|
|
301
|
+
return {
|
|
302
|
+
"status": "ok" if ctx.state == AgentState.DONE else "failed",
|
|
303
|
+
"response": message,
|
|
304
|
+
"workspace": str(self.agent_root),
|
|
305
|
+
"steps": ctx.transcript,
|
|
306
|
+
"state_history": ctx.state_history,
|
|
307
|
+
"final_state": ctx.state.value,
|
|
308
|
+
"created_files": collect_created_files(ctx.transcript),
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async def resume(
|
|
312
|
+
self,
|
|
313
|
+
req: AgentResumeRequest,
|
|
314
|
+
request: Request,
|
|
315
|
+
) -> Dict[str, Any]:
|
|
316
|
+
"""Resume a paused agent after human approval of the plan."""
|
|
317
|
+
current_user = self.require_user(request)
|
|
318
|
+
with self._pending_lock:
|
|
319
|
+
now = time.monotonic()
|
|
320
|
+
for context_id, pending in list(self._pending.items()):
|
|
321
|
+
if now - pending[4] >= self._pending_ttl_seconds:
|
|
322
|
+
self._pending.pop(context_id, None)
|
|
323
|
+
entry = self._pending.get(req.context_id)
|
|
324
|
+
if entry and entry[3] == current_user:
|
|
325
|
+
self._pending.pop(req.context_id, None)
|
|
326
|
+
if not entry:
|
|
327
|
+
raise HTTPException(
|
|
328
|
+
status_code=404,
|
|
329
|
+
detail="Agent context not found or expired. Start a new request.",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
ctx, original_request, language_hint, original_user, _created_at = entry
|
|
333
|
+
if original_user != current_user:
|
|
334
|
+
raise HTTPException(
|
|
335
|
+
status_code=403,
|
|
336
|
+
detail="Agent context belongs to another user.",
|
|
337
|
+
)
|
|
338
|
+
if not req.approved:
|
|
339
|
+
return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
|
|
340
|
+
if req.modified_plan:
|
|
341
|
+
ctx.plan = req.modified_plan
|
|
342
|
+
ctx.transcript[-1].update(ctx.plan)
|
|
343
|
+
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
344
|
+
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
345
|
+
self.runtime.approve(ctx, current_user)
|
|
346
|
+
return await self._finish(
|
|
347
|
+
ctx,
|
|
348
|
+
original_request,
|
|
349
|
+
language_hint,
|
|
350
|
+
current_user,
|
|
351
|
+
max(1, min(original_request.max_steps, 50)),
|
|
352
|
+
3,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
__all__ = ["AgentHTTPController"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Stable request contracts shared by the chat HTTP submodules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChatRequest(BaseModel):
|
|
11
|
+
message: str
|
|
12
|
+
conversation_id: Optional[str] = None
|
|
13
|
+
client_url: Optional[str] = None
|
|
14
|
+
model: Optional[str] = None
|
|
15
|
+
max_tokens: int = 2048
|
|
16
|
+
temperature: float = 0.2
|
|
17
|
+
stream: bool = True
|
|
18
|
+
context: Optional[str] = None
|
|
19
|
+
source: Optional[str] = None
|
|
20
|
+
user_email: Optional[str] = None
|
|
21
|
+
user_nickname: Optional[str] = None
|
|
22
|
+
image_data: Optional[str] = None
|
|
23
|
+
allow_file_context: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AgentRequest(BaseModel):
|
|
27
|
+
message: str
|
|
28
|
+
conversation_id: Optional[str] = None
|
|
29
|
+
source: Optional[str] = None
|
|
30
|
+
max_steps: int = 25
|
|
31
|
+
temperature: float = 0.1
|
|
32
|
+
user_email: Optional[str] = None
|
|
33
|
+
user_nickname: Optional[str] = None
|
|
34
|
+
workspace_id: Optional[str] = None
|
|
35
|
+
planning_model: Optional[str] = None
|
|
36
|
+
executing_model: Optional[str] = None
|
|
37
|
+
reviewing_model: Optional[str] = None
|
|
38
|
+
human_in_loop: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AgentResumeRequest(BaseModel):
|
|
42
|
+
context_id: str
|
|
43
|
+
approved: bool = True
|
|
44
|
+
modified_plan: Optional[dict] = None
|
|
45
|
+
executing_model: Optional[str] = None
|
|
46
|
+
reviewing_model: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AgentEvalRequest(BaseModel):
|
|
50
|
+
skill: str
|
|
51
|
+
case_id: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["AgentEvalRequest", "AgentRequest", "AgentResumeRequest", "ChatRequest"]
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Screenshot ingestion and Knowledge-Graph-backed document generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
17
|
+
from PIL import Image
|
|
18
|
+
|
|
19
|
+
from latticeai.core.context_builder import (
|
|
20
|
+
format_sources_footnote,
|
|
21
|
+
retrieve_context_for_generation,
|
|
22
|
+
)
|
|
23
|
+
from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_screenshot_context(image_data: Optional[str]) -> str:
|
|
27
|
+
if not image_data:
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
lines = ["[SCREENSHOT INGESTION]"]
|
|
31
|
+
try:
|
|
32
|
+
image_bytes = base64.b64decode(image_data)
|
|
33
|
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
|
34
|
+
lines.append(f"- image_size: {image.width}x{image.height}")
|
|
35
|
+
lines.append(f"- image_mode: {image.mode}")
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
lines.append(f"- image_decode_error: {exc}")
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
tesseract_path = shutil.which("tesseract")
|
|
41
|
+
if not tesseract_path:
|
|
42
|
+
lines.append("- ocr: unavailable; install `tesseract` to enable OCR text extraction.")
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
temp_path = None
|
|
46
|
+
try:
|
|
47
|
+
with tempfile.NamedTemporaryFile(
|
|
48
|
+
prefix="ltcai-screenshot-",
|
|
49
|
+
suffix=".png",
|
|
50
|
+
delete=False,
|
|
51
|
+
) as temp:
|
|
52
|
+
temp.write(image_bytes)
|
|
53
|
+
temp_path = temp.name
|
|
54
|
+
|
|
55
|
+
ocr_text = ""
|
|
56
|
+
for language in ("kor+eng", "eng"):
|
|
57
|
+
completed = subprocess.run(
|
|
58
|
+
[tesseract_path, temp_path, "stdout", "-l", language, "--psm", "6"],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
timeout=20,
|
|
62
|
+
check=False,
|
|
63
|
+
)
|
|
64
|
+
if completed.returncode == 0 and completed.stdout.strip():
|
|
65
|
+
ocr_text = completed.stdout.strip()
|
|
66
|
+
lines.append(f"- ocr_language: {language}")
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
if ocr_text:
|
|
70
|
+
lines.extend(("- ocr_text:", ocr_text[:4000]))
|
|
71
|
+
else:
|
|
72
|
+
lines.append("- ocr: no text extracted.")
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
lines.append(f"- ocr_error: {exc}")
|
|
75
|
+
finally:
|
|
76
|
+
if temp_path:
|
|
77
|
+
try:
|
|
78
|
+
Path(temp_path).unlink()
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
return "\n".join(lines)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class DocumentPreparation:
|
|
86
|
+
is_document: bool
|
|
87
|
+
context: str
|
|
88
|
+
retrieval: Optional[Dict[str, Any]]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class DocumentGenerationCoordinator:
|
|
92
|
+
"""Own per-conversation document sessions and generation finalization."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
model_router: Any,
|
|
98
|
+
knowledge_graph: Any,
|
|
99
|
+
enable_graph: bool,
|
|
100
|
+
chat_service: Any,
|
|
101
|
+
notify: Any,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._router = model_router
|
|
104
|
+
self._graph = knowledge_graph
|
|
105
|
+
self._enable_graph = bool(enable_graph)
|
|
106
|
+
self._chat_service = chat_service
|
|
107
|
+
self._notify = notify
|
|
108
|
+
self._sessions: Dict[tuple[str, str, str], DocumentGenerationSession] = {}
|
|
109
|
+
|
|
110
|
+
def prepare(
|
|
111
|
+
self,
|
|
112
|
+
req: Any,
|
|
113
|
+
context: str,
|
|
114
|
+
*,
|
|
115
|
+
workspace_id: Optional[str],
|
|
116
|
+
) -> DocumentPreparation:
|
|
117
|
+
is_document = detect_document_intent(req.message)
|
|
118
|
+
retrieval = None
|
|
119
|
+
if self._enable_graph and self._graph and is_document:
|
|
120
|
+
try:
|
|
121
|
+
retrieval = retrieve_context_for_generation(
|
|
122
|
+
self._graph,
|
|
123
|
+
req.message,
|
|
124
|
+
max_results=10,
|
|
125
|
+
max_hops=2,
|
|
126
|
+
allowed_workspaces={workspace_id} if workspace_id else None,
|
|
127
|
+
)
|
|
128
|
+
graph_context = retrieval.get("context_markdown", "")
|
|
129
|
+
if graph_context:
|
|
130
|
+
context += (
|
|
131
|
+
"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n"
|
|
132
|
+
+ graph_context
|
|
133
|
+
)
|
|
134
|
+
logging.debug(
|
|
135
|
+
"Document generation context retrieved from knowledge graph."
|
|
136
|
+
)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
logging.warning("Knowledge graph reinforcement skipped: %s", exc)
|
|
139
|
+
return DocumentPreparation(is_document, context, retrieval)
|
|
140
|
+
|
|
141
|
+
async def response(
|
|
142
|
+
self,
|
|
143
|
+
req: Any,
|
|
144
|
+
preparation: DocumentPreparation,
|
|
145
|
+
*,
|
|
146
|
+
model_id: str,
|
|
147
|
+
effective_email: Optional[str],
|
|
148
|
+
workspace_id: Optional[str],
|
|
149
|
+
history_meta: Dict[str, Any],
|
|
150
|
+
trace_seed: Dict[str, Any],
|
|
151
|
+
):
|
|
152
|
+
if not (
|
|
153
|
+
preparation.is_document
|
|
154
|
+
and self._enable_graph
|
|
155
|
+
and self._graph
|
|
156
|
+
):
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
key = (
|
|
160
|
+
effective_email or "",
|
|
161
|
+
workspace_id or "",
|
|
162
|
+
req.conversation_id or "default",
|
|
163
|
+
)
|
|
164
|
+
session = self._sessions.setdefault(key, DocumentGenerationSession())
|
|
165
|
+
graph_markdown = (preparation.retrieval or {}).get("context_markdown", "")
|
|
166
|
+
system_prompt = session.get_system_prompt(graph_markdown)
|
|
167
|
+
footnote = format_sources_footnote((preparation.retrieval or {}).get("sources", []))
|
|
168
|
+
|
|
169
|
+
if req.stream:
|
|
170
|
+
async def stream_document():
|
|
171
|
+
collected = []
|
|
172
|
+
stream_error = None
|
|
173
|
+
try:
|
|
174
|
+
async for chunk in self._router.stream_generate_document_as(
|
|
175
|
+
model_id,
|
|
176
|
+
req.message,
|
|
177
|
+
system_prompt,
|
|
178
|
+
max_tokens=req.max_tokens or 8192,
|
|
179
|
+
temperature=req.temperature or 0.3,
|
|
180
|
+
):
|
|
181
|
+
collected.append(chunk)
|
|
182
|
+
yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n"
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
stream_error = str(exc)
|
|
185
|
+
logging.warning("document stream failed: %s", exc)
|
|
186
|
+
yield f"data: {json.dumps({'error': stream_error}, ensure_ascii=False)}\n\n"
|
|
187
|
+
|
|
188
|
+
full_text = "".join(collected)
|
|
189
|
+
if footnote:
|
|
190
|
+
yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
|
|
191
|
+
full_text += footnote
|
|
192
|
+
if stream_error:
|
|
193
|
+
full_text = (
|
|
194
|
+
f"{full_text}\n\n[stream_error] {stream_error}"
|
|
195
|
+
if full_text
|
|
196
|
+
else f"[stream_error] {stream_error}"
|
|
197
|
+
)
|
|
198
|
+
session.update(graph_markdown, full_text, req.conversation_id)
|
|
199
|
+
trace_record = await self._chat_service.persist_answer(
|
|
200
|
+
question=req.message,
|
|
201
|
+
response=full_text,
|
|
202
|
+
conversation_id=req.conversation_id,
|
|
203
|
+
user_email=effective_email,
|
|
204
|
+
user_nickname=req.user_nickname,
|
|
205
|
+
source=req.source,
|
|
206
|
+
trace=trace_seed,
|
|
207
|
+
workspace_id=workspace_id,
|
|
208
|
+
history_meta=history_meta,
|
|
209
|
+
notify=self._notify,
|
|
210
|
+
)
|
|
211
|
+
yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
|
|
212
|
+
yield "data: [DONE]\n\n"
|
|
213
|
+
|
|
214
|
+
return StreamingResponse(
|
|
215
|
+
stream_document(),
|
|
216
|
+
media_type="text/event-stream",
|
|
217
|
+
headers={"X-Model": model_id, "X-Doc-Gen": "true"},
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
result = await self._router.generate_document_as(
|
|
221
|
+
model_id,
|
|
222
|
+
req.message,
|
|
223
|
+
system_prompt,
|
|
224
|
+
max_tokens=req.max_tokens or 8192,
|
|
225
|
+
temperature=req.temperature or 0.3,
|
|
226
|
+
)
|
|
227
|
+
if footnote:
|
|
228
|
+
result += footnote
|
|
229
|
+
response_text = str(result)
|
|
230
|
+
session.update(graph_markdown, response_text, req.conversation_id)
|
|
231
|
+
trace_record = await self._chat_service.persist_answer(
|
|
232
|
+
question=req.message,
|
|
233
|
+
response=response_text,
|
|
234
|
+
conversation_id=req.conversation_id,
|
|
235
|
+
user_email=effective_email,
|
|
236
|
+
user_nickname=req.user_nickname,
|
|
237
|
+
source=req.source,
|
|
238
|
+
trace=trace_seed,
|
|
239
|
+
workspace_id=workspace_id,
|
|
240
|
+
history_meta=history_meta,
|
|
241
|
+
notify=self._notify,
|
|
242
|
+
)
|
|
243
|
+
return JSONResponse(
|
|
244
|
+
content={
|
|
245
|
+
"response": response_text,
|
|
246
|
+
"trace_id": trace_record["id"],
|
|
247
|
+
"trace": trace_record,
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = [
|
|
253
|
+
"DocumentGenerationCoordinator",
|
|
254
|
+
"DocumentPreparation",
|
|
255
|
+
"extract_screenshot_context",
|
|
256
|
+
]
|