ltcai 8.2.0 → 8.4.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 +27 -19
- package/docs/CHANGELOG.md +59 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +43 -0
- package/docs/DEVELOPMENT.md +8 -8
- package/docs/LEGACY_COMPATIBILITY.md +22 -2
- package/docs/ONBOARDING.md +38 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +5 -4
- package/docs/kg-schema.md +3 -3
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/ingest.py +40 -8
- package/lattice_brain/runtime/agent_runtime.py +22 -37
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/workflow.py +26 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +230 -51
- package/latticeai/api/knowledge_graph.py +33 -0
- package/latticeai/api/local_files.py +2 -0
- package/latticeai/api/tools.py +1 -0
- package/latticeai/api/workflow_designer.py +5 -4
- package/latticeai/core/legacy_compatibility.py +174 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/architecture_readiness.py +37 -3
- package/latticeai/services/model_catalog.py +6 -0
- package/latticeai/services/model_engines.py +12 -21
- package/latticeai/services/model_runtime.py +17 -23
- package/latticeai/services/product_readiness.py +45 -14
- package/llm_router.py +7 -28
- package/mcp_registry.py +7 -24
- package/package.json +1 -1
- package/scripts/pts-claudecode-discord-bridge.mjs +2 -1
- 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 +10 -10
- package/static/app/assets/{Act-D9jIknFd.js → Act-D5mo4tE4.js} +1 -1
- package/static/app/assets/{Brain-CFOtWbPN.js → Brain-BVWyQw8A.js} +1 -1
- package/static/app/assets/{Capture-Q4WYzwr5.js → Capture-C1R6GT0t.js} +1 -1
- package/static/app/assets/{Library-C5Q2yWee.js → Library-C2wIxpTs.js} +1 -1
- package/static/app/assets/{System-BLbjdr1_.js → System-DE5GRyQR.js} +1 -1
- package/static/app/assets/{index-BqammyNu.js → index-CoiuIFFP.js} +3 -3
- package/static/app/assets/{primitives-Br8uSfZ4.js → primitives-BdsUNXa6.js} +1 -1
- package/static/app/assets/{textarea-BnhNs1_X.js → textarea-e7qaj6Hm.js} +1 -1
- package/static/app/index.html +1 -1
- package/static/sw.js +1 -1
|
@@ -27,7 +27,7 @@ from dataclasses import dataclass, field
|
|
|
27
27
|
from datetime import datetime
|
|
28
28
|
from typing import Any, Callable, Dict, List, Optional
|
|
29
29
|
|
|
30
|
-
from lattice_brain.runtime.contracts import workflow_run_contract
|
|
30
|
+
from lattice_brain.runtime.contracts import runtime_boundary_contract, workflow_run_contract
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
WORKFLOW_ENGINE_VERSION = "2.2.0"
|
|
@@ -106,6 +106,11 @@ def normalize_definition(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def legacy_steps_from_nodes(nodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
110
|
+
"""Return the compact legacy ``steps`` projection for node workflows."""
|
|
111
|
+
return [{"action": node.get("type"), "node": node.get("id")} for node in nodes]
|
|
112
|
+
|
|
113
|
+
|
|
109
114
|
def validate_definition(workflow: Dict[str, Any]) -> List[str]:
|
|
110
115
|
"""Return a list of validation errors ([] means valid)."""
|
|
111
116
|
errors: List[str] = []
|
|
@@ -269,6 +274,26 @@ class WorkflowEngine:
|
|
|
269
274
|
# against the workflow lifecycle actually executes.
|
|
270
275
|
self.hooks = hooks
|
|
271
276
|
|
|
277
|
+
def boundary(self) -> Dict[str, Any]:
|
|
278
|
+
return runtime_boundary_contract(
|
|
279
|
+
name="WorkflowEngine",
|
|
280
|
+
runtime="workflow",
|
|
281
|
+
entrypoint="lattice_brain.workflow.WorkflowEngine",
|
|
282
|
+
surface="/workflows",
|
|
283
|
+
owns="typed workflow validation, execution, approval suspension, resume, export, and import",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def config(self) -> Dict[str, Any]:
|
|
287
|
+
return {
|
|
288
|
+
"version": WORKFLOW_ENGINE_VERSION,
|
|
289
|
+
"boundary": self.boundary(),
|
|
290
|
+
"node_types": list(NODE_TYPES),
|
|
291
|
+
"runner_families": dict(_RUNNER_FOR),
|
|
292
|
+
"max_steps": _MAX_STEPS,
|
|
293
|
+
"approval_suspend": True,
|
|
294
|
+
"legacy_steps_projection": "lattice_brain.workflow.legacy_steps_from_nodes",
|
|
295
|
+
}
|
|
296
|
+
|
|
272
297
|
def run(self, workflow: Dict[str, Any], *, inputs: Optional[Dict[str, Any]] = None) -> WorkflowRun:
|
|
273
298
|
definition = normalize_definition(workflow)
|
|
274
299
|
errors = validate_definition(definition)
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/chat.py
CHANGED
|
@@ -123,6 +123,90 @@ def is_current_url_request(text: str) -> bool:
|
|
|
123
123
|
def is_clear_command(text: str) -> bool:
|
|
124
124
|
return (text or "").strip().lower() in {"/clear", "/clear_all"}
|
|
125
125
|
|
|
126
|
+
# Path segments intentionally exclude spaces: allowing spaces let the target
|
|
127
|
+
# match swallow preceding words (e.g. "create a text file report.txt" resolved
|
|
128
|
+
# to the whole phrase as the path). Chat file targets are single tokens.
|
|
129
|
+
_FILE_TARGET_RE = re.compile(
|
|
130
|
+
r"(?<![\w.-])(?:[~./\\]?[\w.@()+-]+[\\/])*[\w.@()+-]+"
|
|
131
|
+
r"\.(?:py|js|jsx|ts|tsx|md|markdown|txt|json|yaml|yml|toml|html|css|csv|xml|pdf|docx|xlsx|pptx|sh|sql)",
|
|
132
|
+
re.IGNORECASE,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_file_action_request(text: str) -> bool:
|
|
137
|
+
"""Return True for chat requests that should execute file tools, not prose.
|
|
138
|
+
|
|
139
|
+
The Brain chat composer posts to ``/chat``. Without this gate, explicit
|
|
140
|
+
side-effect requests such as "create hello.md" are handled as plain model
|
|
141
|
+
generation, which commonly produces a code block instead of a real file.
|
|
142
|
+
Keep the gate narrow so normal Q&A and "how do I create a file?" stay in
|
|
143
|
+
ordinary chat.
|
|
144
|
+
"""
|
|
145
|
+
raw = (text or "").strip()
|
|
146
|
+
if not raw:
|
|
147
|
+
return False
|
|
148
|
+
lower = raw.lower()
|
|
149
|
+
|
|
150
|
+
if any(phrase in lower for phrase in (
|
|
151
|
+
"how to ", "how do i ", "방법", "어떻게", "예시", "sample", "example",
|
|
152
|
+
)) and not any(phrase in lower for phrase in (
|
|
153
|
+
"actually create", "real file", "실제로", "파일로", "저장해", "만들어",
|
|
154
|
+
)):
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
has_target = bool(_FILE_TARGET_RE.search(raw))
|
|
158
|
+
has_file_word = any(word in lower for word in (
|
|
159
|
+
"file", "파일", "문서", "artifact", "아티팩트", "save as", "저장",
|
|
160
|
+
))
|
|
161
|
+
has_action = any(word in lower for word in (
|
|
162
|
+
"create", "make", "write", "save", "generate", "edit", "update",
|
|
163
|
+
"만들", "생성", "작성", "저장", "수정", "써줘", "만들어줘",
|
|
164
|
+
))
|
|
165
|
+
|
|
166
|
+
if not has_action:
|
|
167
|
+
return False
|
|
168
|
+
if has_target and has_action:
|
|
169
|
+
return True
|
|
170
|
+
return has_file_word and has_action
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def file_action_target(text: str) -> Optional[str]:
|
|
174
|
+
"""Extract the first explicit workspace file target from a request."""
|
|
175
|
+
match = _FILE_TARGET_RE.search(text or "")
|
|
176
|
+
if not match:
|
|
177
|
+
return None
|
|
178
|
+
return match.group(0).strip().strip("`'\".,:;)]}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def inline_file_action_content(text: str) -> Optional[str]:
|
|
182
|
+
"""Extract short user-provided content for deterministic file writes."""
|
|
183
|
+
raw = (text or "").strip()
|
|
184
|
+
# Each pattern requires an explicit binder so ambiguous words like "text"
|
|
185
|
+
# ("create a text file report.txt") or a bare "with" ("report.md with a
|
|
186
|
+
# summary of X") do not get captured as literal file content.
|
|
187
|
+
patterns = [
|
|
188
|
+
r"(?:내용|본문)\s*(?:은|는|이에요|입니다)\s*(.+)$",
|
|
189
|
+
r"(?:내용|본문|content|body|text)\s*[:=]\s*(.+)$",
|
|
190
|
+
r"(?:content|body)\s+(?:is|as)\s+(.+)$",
|
|
191
|
+
r"(?:with the content|with content|containing)\s+(.+)$",
|
|
192
|
+
]
|
|
193
|
+
for pattern in patterns:
|
|
194
|
+
match = re.search(pattern, raw, flags=re.IGNORECASE | re.DOTALL)
|
|
195
|
+
if match:
|
|
196
|
+
content = match.group(1).strip()
|
|
197
|
+
return content.strip("`'\"")
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def strip_generated_file_content(text: str) -> str:
|
|
202
|
+
"""Remove common chat wrappers when a model is asked for file content only."""
|
|
203
|
+
content = (text or "").strip()
|
|
204
|
+
fenced = re.search(r"```(?:[\w.+-]+)?\s*(.*?)\s*```", content, flags=re.DOTALL)
|
|
205
|
+
if fenced:
|
|
206
|
+
content = fenced.group(1).strip()
|
|
207
|
+
return content
|
|
208
|
+
|
|
209
|
+
|
|
126
210
|
def format_network_status(info: Dict) -> str:
|
|
127
211
|
lines = [
|
|
128
212
|
f"내부 IP: {info.get('local_ip') or '확인 안 됨'}",
|
|
@@ -243,11 +327,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
243
327
|
user_email=user_email,
|
|
244
328
|
conversation_id=conversation_id,
|
|
245
329
|
)
|
|
246
|
-
|
|
330
|
+
|
|
247
331
|
def extract_screenshot_context(image_data: Optional[str]) -> str:
|
|
248
332
|
if not image_data:
|
|
249
333
|
return ""
|
|
250
|
-
|
|
334
|
+
|
|
251
335
|
lines = ["[SCREENSHOT INGESTION]"]
|
|
252
336
|
image_bytes = b""
|
|
253
337
|
try:
|
|
@@ -258,18 +342,18 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
258
342
|
except Exception as e:
|
|
259
343
|
lines.append(f"- image_decode_error: {e}")
|
|
260
344
|
return "\n".join(lines)
|
|
261
|
-
|
|
345
|
+
|
|
262
346
|
tesseract_path = shutil.which("tesseract")
|
|
263
347
|
if not tesseract_path:
|
|
264
348
|
lines.append("- ocr: unavailable; install `tesseract` to enable OCR text extraction.")
|
|
265
349
|
return "\n".join(lines)
|
|
266
|
-
|
|
350
|
+
|
|
267
351
|
temp_path = None
|
|
268
352
|
try:
|
|
269
353
|
with tempfile.NamedTemporaryFile(prefix="ltcai-screenshot-", suffix=".png", delete=False) as temp:
|
|
270
354
|
temp.write(image_bytes)
|
|
271
355
|
temp_path = temp.name
|
|
272
|
-
|
|
356
|
+
|
|
273
357
|
ocr_text = ""
|
|
274
358
|
for lang in ("kor+eng", "eng"):
|
|
275
359
|
completed = subprocess.run(
|
|
@@ -283,7 +367,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
283
367
|
ocr_text = completed.stdout.strip()
|
|
284
368
|
lines.append(f"- ocr_language: {lang}")
|
|
285
369
|
break
|
|
286
|
-
|
|
370
|
+
|
|
287
371
|
if ocr_text:
|
|
288
372
|
lines.append("- ocr_text:")
|
|
289
373
|
lines.append(ocr_text[:4000])
|
|
@@ -297,7 +381,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
297
381
|
Path(temp_path).unlink()
|
|
298
382
|
except OSError:
|
|
299
383
|
pass
|
|
300
|
-
|
|
384
|
+
|
|
301
385
|
return "\n".join(lines)
|
|
302
386
|
|
|
303
387
|
_AGENT_RUNTIME = context.chat_agent_runtime
|
|
@@ -329,7 +413,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
329
413
|
"conversation_id": req.conversation_id,
|
|
330
414
|
"workspace_id": workspace_scope_from_request(request),
|
|
331
415
|
}
|
|
332
|
-
|
|
416
|
+
|
|
333
417
|
if is_network_status_request(req.message):
|
|
334
418
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
335
419
|
save_to_history("user", history_message, **history_meta, **history_user)
|
|
@@ -347,7 +431,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
347
431
|
headers={"X-Model": "network_status"},
|
|
348
432
|
)
|
|
349
433
|
return JSONResponse(content={"response": answer})
|
|
350
|
-
|
|
434
|
+
|
|
351
435
|
if is_clear_command(req.message):
|
|
352
436
|
command = req.message.strip().lower()
|
|
353
437
|
clear_scope = "all" if command == "/clear_all" else "conversation"
|
|
@@ -392,7 +476,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
392
476
|
headers={"X-Model": "history"},
|
|
393
477
|
)
|
|
394
478
|
return JSONResponse(content={"response": answer})
|
|
395
|
-
|
|
479
|
+
|
|
396
480
|
if is_current_url_request(req.message) and req.client_url:
|
|
397
481
|
answer = f"현재 페이지 URL: {req.client_url}"
|
|
398
482
|
save_to_history("user", req.message, **history_meta, **history_user)
|
|
@@ -406,7 +490,74 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
406
490
|
headers={"X-Model": "client_url"},
|
|
407
491
|
)
|
|
408
492
|
return JSONResponse(content={"response": answer})
|
|
409
|
-
|
|
493
|
+
|
|
494
|
+
if is_file_action_request(req.message):
|
|
495
|
+
target_path = file_action_target(req.message)
|
|
496
|
+
if target_path:
|
|
497
|
+
content = inline_file_action_content(req.message)
|
|
498
|
+
if content is None and router.current_model_id:
|
|
499
|
+
if req.model and req.model != router.current_model_id:
|
|
500
|
+
if req.model not in router.loaded_model_ids:
|
|
501
|
+
raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
|
|
502
|
+
router.switch_model(req.model)
|
|
503
|
+
generation_context = (
|
|
504
|
+
"Create the exact content for the requested file. "
|
|
505
|
+
"Return only the file bytes as plain text. "
|
|
506
|
+
"Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
|
|
507
|
+
f"Target path: {target_path}\n"
|
|
508
|
+
f"User request: {req.message}"
|
|
509
|
+
)
|
|
510
|
+
raw_content = await router.generate_as(
|
|
511
|
+
router.current_model_id,
|
|
512
|
+
message="Return only the requested file content.",
|
|
513
|
+
context=generation_context,
|
|
514
|
+
max_tokens=req.max_tokens,
|
|
515
|
+
temperature=req.temperature,
|
|
516
|
+
)
|
|
517
|
+
content = strip_generated_file_content(str(raw_content))
|
|
518
|
+
if content is None:
|
|
519
|
+
content = ""
|
|
520
|
+
try:
|
|
521
|
+
result = execute_tool("write_file", {"path": target_path, "content": content})
|
|
522
|
+
except ToolError as exc:
|
|
523
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
524
|
+
answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
|
|
525
|
+
created_files = [{
|
|
526
|
+
"path": result.get("path") or target_path,
|
|
527
|
+
"filename": Path(result.get("path") or target_path).name,
|
|
528
|
+
"bytes": result.get("bytes", 0),
|
|
529
|
+
"action": "write_file",
|
|
530
|
+
}]
|
|
531
|
+
notify_chat_message("user", req.message, req.source)
|
|
532
|
+
notify_chat_message("assistant", answer, req.source)
|
|
533
|
+
payload = {
|
|
534
|
+
"status": "ok",
|
|
535
|
+
"response": answer,
|
|
536
|
+
"workspace": str(AGENT_ROOT),
|
|
537
|
+
"steps": [{
|
|
538
|
+
"state": AgentState.EXECUTING.value,
|
|
539
|
+
"action": "write_file",
|
|
540
|
+
"args": {"path": target_path},
|
|
541
|
+
"result": result,
|
|
542
|
+
}],
|
|
543
|
+
"state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
|
|
544
|
+
"final_state": AgentState.DONE.value,
|
|
545
|
+
"created_files": created_files,
|
|
546
|
+
"routed_to_agent": True,
|
|
547
|
+
"action_route": "direct_write_file",
|
|
548
|
+
}
|
|
549
|
+
if req.stream:
|
|
550
|
+
async def _stream_file_result():
|
|
551
|
+
yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
|
|
552
|
+
yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
|
|
553
|
+
yield "data: [DONE]\n\n"
|
|
554
|
+
return StreamingResponse(
|
|
555
|
+
_stream_file_result(),
|
|
556
|
+
media_type="text/event-stream",
|
|
557
|
+
headers={"X-Model": router.current_model_id or "tool", "X-Routed-To": "agent"},
|
|
558
|
+
)
|
|
559
|
+
return JSONResponse(content=payload)
|
|
560
|
+
|
|
410
561
|
if not router.current_model_id:
|
|
411
562
|
detail = "No model loaded. Call /models/load first."
|
|
412
563
|
if CONFIG.is_public:
|
|
@@ -420,12 +571,40 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
420
571
|
"action": "load_model",
|
|
421
572
|
},
|
|
422
573
|
)
|
|
423
|
-
|
|
574
|
+
|
|
424
575
|
if req.model and req.model != router.current_model_id:
|
|
425
576
|
if req.model not in router.loaded_model_ids:
|
|
426
577
|
raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
|
|
427
578
|
router.switch_model(req.model)
|
|
428
|
-
|
|
579
|
+
|
|
580
|
+
if is_file_action_request(req.message):
|
|
581
|
+
agent_req = AgentRequest(
|
|
582
|
+
message=req.message,
|
|
583
|
+
conversation_id=req.conversation_id,
|
|
584
|
+
source=req.source or "web",
|
|
585
|
+
max_steps=25,
|
|
586
|
+
temperature=min(req.temperature, 0.2),
|
|
587
|
+
user_email=effective_email,
|
|
588
|
+
user_nickname=req.user_nickname,
|
|
589
|
+
workspace_id=workspace_scope_from_request(request),
|
|
590
|
+
)
|
|
591
|
+
result = await agent(agent_req, request)
|
|
592
|
+
answer = str(result.get("response") or "파일 작업을 처리했습니다.")
|
|
593
|
+
notify_chat_message("user", req.message, req.source)
|
|
594
|
+
notify_chat_message("assistant", answer, req.source)
|
|
595
|
+
result["routed_to_agent"] = True
|
|
596
|
+
if req.stream:
|
|
597
|
+
async def _stream_agent_result():
|
|
598
|
+
yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
|
|
599
|
+
yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
|
|
600
|
+
yield "data: [DONE]\n\n"
|
|
601
|
+
return StreamingResponse(
|
|
602
|
+
_stream_agent_result(),
|
|
603
|
+
media_type="text/event-stream",
|
|
604
|
+
headers={"X-Model": router.current_model_id, "X-Routed-To": "agent"},
|
|
605
|
+
)
|
|
606
|
+
return JSONResponse(content=result)
|
|
607
|
+
|
|
429
608
|
lang = detect_language(req.message)
|
|
430
609
|
context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
|
|
431
610
|
# v4 Context System: one budgeted, provenance-carrying assembly
|
|
@@ -462,12 +641,12 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
462
641
|
print("📝 Document generation context retrieved from knowledge graph.")
|
|
463
642
|
except Exception as e:
|
|
464
643
|
logging.warning("Knowledge graph reinforcement skipped: %s", e)
|
|
465
|
-
|
|
644
|
+
|
|
466
645
|
if req.image_data:
|
|
467
646
|
screenshot_context = extract_screenshot_context(req.image_data)
|
|
468
647
|
if screenshot_context:
|
|
469
648
|
context += f"\n\n{screenshot_context}"
|
|
470
|
-
|
|
649
|
+
|
|
471
650
|
if CONFIG.auto_read_chat_paths:
|
|
472
651
|
_file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
|
|
473
652
|
requested_paths = [_m.group(1).strip() for _m in _file_path_re.finditer(req.message or "")]
|
|
@@ -487,7 +666,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
487
666
|
"Attach the file, upload it, or use an approved local-file tool flow."
|
|
488
667
|
),
|
|
489
668
|
)
|
|
490
|
-
|
|
669
|
+
|
|
491
670
|
trace_seed = CHAT_SERVICE.build_graph_trace(
|
|
492
671
|
req.message,
|
|
493
672
|
KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
|
|
@@ -497,11 +676,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
497
676
|
# Persisted with the answer trace: 'why is this in my context?'
|
|
498
677
|
# is answerable from the stored record (UI surface lands in T9b).
|
|
499
678
|
trace_seed["context_assembly"] = context_trace
|
|
500
|
-
|
|
679
|
+
|
|
501
680
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
502
681
|
save_to_history("user", history_message, **history_meta, **history_user)
|
|
503
682
|
notify_chat_message("user", req.message, req.source)
|
|
504
|
-
|
|
683
|
+
|
|
505
684
|
if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
506
685
|
conv_key = req.conversation_id or "default"
|
|
507
686
|
session = _doc_gen_sessions.get(conv_key)
|
|
@@ -512,7 +691,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
512
691
|
system_prompt = session.get_system_prompt(graph_md)
|
|
513
692
|
sources = (doc_gen_context_result or {}).get("sources", [])
|
|
514
693
|
footnote = format_sources_footnote(sources)
|
|
515
|
-
|
|
694
|
+
|
|
516
695
|
if req.stream:
|
|
517
696
|
async def _stream_doc_gen():
|
|
518
697
|
collected = []
|
|
@@ -563,7 +742,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
563
742
|
)
|
|
564
743
|
notify_chat_message("assistant", str(result), req.source)
|
|
565
744
|
return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
|
|
566
|
-
|
|
745
|
+
|
|
567
746
|
if req.stream:
|
|
568
747
|
recent_context = recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
|
|
569
748
|
stream_context = context
|
|
@@ -593,9 +772,9 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
593
772
|
else:
|
|
594
773
|
history_context = recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
|
|
595
774
|
full_context = f"{history_context}\n{context}" if context else history_context
|
|
596
|
-
|
|
775
|
+
|
|
597
776
|
result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
|
|
598
|
-
|
|
777
|
+
|
|
599
778
|
save_to_history("assistant", str(result), **history_meta, **history_user)
|
|
600
779
|
trace_record = CHAT_SERVICE.record_trace(
|
|
601
780
|
question=req.message,
|
|
@@ -605,22 +784,22 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
605
784
|
trace=trace_seed,
|
|
606
785
|
)
|
|
607
786
|
notify_chat_message("assistant", str(result), req.source)
|
|
608
|
-
|
|
787
|
+
|
|
609
788
|
return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
|
|
610
|
-
|
|
789
|
+
|
|
611
790
|
|
|
612
791
|
@api_router.get("/history")
|
|
613
792
|
async def fetch_history(request: Request):
|
|
614
793
|
"""웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
|
|
615
794
|
require_user(request)
|
|
616
795
|
return get_history()
|
|
617
|
-
|
|
796
|
+
|
|
618
797
|
@api_router.get("/history/conversations")
|
|
619
798
|
async def fetch_history_conversations(request: Request):
|
|
620
799
|
"""저장된 히스토리를 대화 단위로 묶어 반환합니다."""
|
|
621
800
|
require_user(request)
|
|
622
801
|
return group_history_conversations()
|
|
623
|
-
|
|
802
|
+
|
|
624
803
|
@api_router.get("/history/conversations/{conversation_id:path}")
|
|
625
804
|
async def fetch_history_conversation(conversation_id: str, request: Request):
|
|
626
805
|
"""선택한 대화의 메시지를 반환합니다."""
|
|
@@ -629,7 +808,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
629
808
|
if not messages:
|
|
630
809
|
raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
|
|
631
810
|
return {"id": conversation_id, "messages": messages}
|
|
632
|
-
|
|
811
|
+
|
|
633
812
|
|
|
634
813
|
@api_router.delete("/history/conversations/{conversation_id:path}")
|
|
635
814
|
async def delete_history_conversation(conversation_id: str, request: Request):
|
|
@@ -645,7 +824,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
645
824
|
kept=result.get("kept", 0),
|
|
646
825
|
)
|
|
647
826
|
return result
|
|
648
|
-
|
|
827
|
+
|
|
649
828
|
|
|
650
829
|
@api_router.delete("/history")
|
|
651
830
|
async def delete_history(request: Request, keep_last: int = 0):
|
|
@@ -659,7 +838,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
659
838
|
kept=result.get("kept", 0),
|
|
660
839
|
)
|
|
661
840
|
return result
|
|
662
|
-
|
|
841
|
+
|
|
663
842
|
@api_router.get("/history/search")
|
|
664
843
|
async def search_history(q: str, request: Request):
|
|
665
844
|
"""키워드로 채팅 히스토리를 검색합니다."""
|
|
@@ -676,7 +855,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
676
855
|
grouped[cid] = {"conversation_id": cid, "title": conversation_title(item), "messages": []}
|
|
677
856
|
grouped[cid]["messages"].append(item)
|
|
678
857
|
return {"results": list(grouped.values())[-30:], "query": q}
|
|
679
|
-
|
|
858
|
+
|
|
680
859
|
async def _stream_chat(
|
|
681
860
|
req: ChatRequest,
|
|
682
861
|
context: str = "",
|
|
@@ -696,7 +875,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
696
875
|
clean_chunk = chunk.split("text='")[1].split("', token=")[0].replace('\\n', '\n').replace('\\\\n', '\n')
|
|
697
876
|
except Exception:
|
|
698
877
|
pass
|
|
699
|
-
|
|
878
|
+
|
|
700
879
|
full_response += str(clean_chunk)
|
|
701
880
|
yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
|
|
702
881
|
history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
|
|
@@ -724,7 +903,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
724
903
|
schema_path = skill_dir / "schema.json"
|
|
725
904
|
if not schema_path.exists():
|
|
726
905
|
raise HTTPException(404, detail=f"Skill '{req.skill}' not found or missing schema.json")
|
|
727
|
-
|
|
906
|
+
|
|
728
907
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
729
908
|
eval_cases = schema.get("evals", [])
|
|
730
909
|
if req.case_id:
|
|
@@ -732,7 +911,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
732
911
|
if not eval_cases:
|
|
733
912
|
return {"skill": req.skill, "total": 0, "passed": 0, "failed": 0, "results": [],
|
|
734
913
|
"message": "No eval cases defined in schema.json"}
|
|
735
|
-
|
|
914
|
+
|
|
736
915
|
action_name = schema.get("action", req.skill)
|
|
737
916
|
results = []
|
|
738
917
|
for case in eval_cases:
|
|
@@ -754,19 +933,19 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
754
933
|
results.append({"id": case_id, "description": case.get("description", ""),
|
|
755
934
|
"passed": False, "error": str(exc),
|
|
756
935
|
"pass_criteria": case.get("pass_criteria", "")})
|
|
757
|
-
|
|
936
|
+
|
|
758
937
|
n_passed = sum(1 for r in results if r.get("passed") is True)
|
|
759
938
|
return {
|
|
760
939
|
"skill": req.skill, "action": action_name,
|
|
761
940
|
"total": len(results), "passed": n_passed, "failed": len(results) - n_passed,
|
|
762
941
|
"results": results,
|
|
763
942
|
}
|
|
764
|
-
|
|
943
|
+
|
|
765
944
|
|
|
766
945
|
@api_router.post("/agent")
|
|
767
946
|
async def agent(req: AgentRequest, request: Request):
|
|
768
947
|
"""Natural-language local agent.
|
|
769
|
-
|
|
948
|
+
|
|
770
949
|
State machine:
|
|
771
950
|
IDLE → PLANNING → WAITING_APPROVAL → EXECUTING → VERIFYING
|
|
772
951
|
↓ ↓
|
|
@@ -779,22 +958,22 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
779
958
|
req.workspace_id = req.workspace_id or workspace_scope_from_request(request)
|
|
780
959
|
if not router.current_model_id:
|
|
781
960
|
raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
|
|
782
|
-
|
|
961
|
+
|
|
783
962
|
ensure_agent_root()
|
|
784
963
|
lang = detect_language(req.message)
|
|
785
964
|
lang_hint = _LANG_HINT[lang]
|
|
786
965
|
max_steps = max(1, min(req.max_steps, 50))
|
|
787
966
|
max_retry = 3
|
|
788
|
-
|
|
967
|
+
|
|
789
968
|
ctx = AgentRunContext()
|
|
790
969
|
ctx.executing_model = req.executing_model
|
|
791
970
|
ctx.reviewing_model = req.reviewing_model
|
|
792
|
-
|
|
971
|
+
|
|
793
972
|
# PLANNING phase
|
|
794
973
|
ctx.state = AgentState.PLANNING
|
|
795
974
|
ctx.state_history.append(ctx.state.value)
|
|
796
975
|
await _AGENT_RUNTIME.plan(ctx, req, lang_hint, current_user, model_id=req.planning_model)
|
|
797
|
-
|
|
976
|
+
|
|
798
977
|
# Human-in-the-loop: pause after planning, return plan to UI
|
|
799
978
|
if req.human_in_loop:
|
|
800
979
|
context_id = secrets.token_urlsafe(16)
|
|
@@ -810,11 +989,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
810
989
|
"executing_model": req.executing_model or router.current_model_id,
|
|
811
990
|
"reviewing_model": req.reviewing_model or router.current_model_id,
|
|
812
991
|
}
|
|
813
|
-
|
|
992
|
+
|
|
814
993
|
# Auto-approve and run to completion (default behaviour)
|
|
815
994
|
_AGENT_RUNTIME.approve(ctx, current_user)
|
|
816
995
|
return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
|
|
817
|
-
|
|
996
|
+
|
|
818
997
|
|
|
819
998
|
async def _agent_finish(
|
|
820
999
|
ctx: AgentRunContext, req: AgentRequest, lang_hint: str,
|
|
@@ -823,7 +1002,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
823
1002
|
"""HTTP glue: drive the runtime to a terminal state, persist, shape the response."""
|
|
824
1003
|
await _AGENT_RUNTIME.run_to_completion(ctx, req, lang_hint, current_user, max_steps, max_retry)
|
|
825
1004
|
asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
|
|
826
|
-
|
|
1005
|
+
|
|
827
1006
|
message = ctx.final_message or "작업을 완료했습니다."
|
|
828
1007
|
save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
|
|
829
1008
|
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
|
|
@@ -850,33 +1029,33 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
850
1029
|
"final_state": ctx.state.value,
|
|
851
1030
|
"created_files": created_files,
|
|
852
1031
|
}
|
|
853
|
-
|
|
1032
|
+
|
|
854
1033
|
|
|
855
1034
|
@api_router.post("/agent/resume")
|
|
856
1035
|
async def agent_resume(req: AgentResumeRequest, request: Request):
|
|
857
1036
|
"""Resume a paused agent after human approval of the plan."""
|
|
858
1037
|
current_user = require_user(request)
|
|
859
|
-
|
|
1038
|
+
|
|
860
1039
|
with _pending_agents_lock:
|
|
861
1040
|
entry = _pending_agents.pop(req.context_id, None)
|
|
862
1041
|
if not entry:
|
|
863
1042
|
raise HTTPException(status_code=404, detail="Agent context not found or expired. Start a new request.")
|
|
864
|
-
|
|
1043
|
+
|
|
865
1044
|
ctx, orig_req, lang_hint, _orig_user = entry
|
|
866
|
-
|
|
1045
|
+
|
|
867
1046
|
if not req.approved:
|
|
868
1047
|
return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
|
|
869
|
-
|
|
1048
|
+
|
|
870
1049
|
if req.modified_plan:
|
|
871
1050
|
ctx.plan = req.modified_plan
|
|
872
1051
|
ctx.transcript[-1].update(ctx.plan) # keep transcript in sync
|
|
873
|
-
|
|
1052
|
+
|
|
874
1053
|
# Apply model overrides from resume request (takes priority over original request)
|
|
875
1054
|
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
876
1055
|
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
877
|
-
|
|
1056
|
+
|
|
878
1057
|
_AGENT_RUNTIME.approve(ctx, current_user)
|
|
879
|
-
|
|
1058
|
+
|
|
880
1059
|
max_steps = max(1, min(orig_req.max_steps, 50))
|
|
881
1060
|
max_retry = 3
|
|
882
1061
|
return await _agent_finish(ctx, orig_req, lang_hint, current_user, max_steps, max_retry)
|
|
@@ -13,6 +13,7 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
15
|
from latticeai.api.ui_redirects import app_redirect
|
|
16
|
+
from lattice_brain.ingestion import IngestionItem
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class KnowledgeGraphIngestRequest(BaseModel):
|
|
@@ -61,6 +62,7 @@ def create_knowledge_graph_router(
|
|
|
61
62
|
require_user: Callable[[Request], str],
|
|
62
63
|
static_dir: Path,
|
|
63
64
|
allowed_workspaces_for: Optional[Callable[[Optional[str]], Any]] = None,
|
|
65
|
+
ingestion_pipeline: Any = None,
|
|
64
66
|
) -> APIRouter:
|
|
65
67
|
router = APIRouter()
|
|
66
68
|
|
|
@@ -191,6 +193,37 @@ def create_knowledge_graph_router(
|
|
|
191
193
|
if event_type not in {"message", "ai_response", "note"}:
|
|
192
194
|
raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
|
|
193
195
|
role = req.role or ("assistant" if event_type == "ai_response" else "user")
|
|
196
|
+
if ingestion_pipeline is not None:
|
|
197
|
+
source_type = "chat_message" if event_type in {"message", "ai_response"} else "note"
|
|
198
|
+
result = ingestion_pipeline.ingest(
|
|
199
|
+
IngestionItem(
|
|
200
|
+
source_type=source_type,
|
|
201
|
+
title=req.title,
|
|
202
|
+
text=req.content,
|
|
203
|
+
source_uri=req.source,
|
|
204
|
+
owner=req.user_email or current_user,
|
|
205
|
+
workspace_id=workspace_id,
|
|
206
|
+
conversation_id=req.conversation_id,
|
|
207
|
+
metadata={
|
|
208
|
+
"type": req.type,
|
|
209
|
+
"role": role,
|
|
210
|
+
"source": req.source or "mcp",
|
|
211
|
+
"user_nickname": req.user_nickname,
|
|
212
|
+
"raw": {
|
|
213
|
+
"type": req.type,
|
|
214
|
+
"title": req.title,
|
|
215
|
+
"content": req.content,
|
|
216
|
+
"workspace_id": workspace_id,
|
|
217
|
+
"metadata": req.metadata or {},
|
|
218
|
+
},
|
|
219
|
+
**(req.metadata or {}),
|
|
220
|
+
},
|
|
221
|
+
),
|
|
222
|
+
user_email=req.user_email or current_user,
|
|
223
|
+
)
|
|
224
|
+
if result.status != "ok":
|
|
225
|
+
raise HTTPException(status_code=500, detail=result.detail or result.status)
|
|
226
|
+
return result.as_dict()
|
|
194
227
|
return kg.ingest_message(
|
|
195
228
|
role,
|
|
196
229
|
req.content,
|