ltcai 9.9.4 → 9.9.9
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 +43 -42
- package/docs/CHANGELOG.md +228 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +3 -1
- package/docs/PERMISSION_MODE.md +107 -0
- package/docs/SURFACE_PARITY.md +45 -9
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/context.py +39 -4
- package/lattice_brain/graph/_kg_common.py +189 -14
- package/lattice_brain/graph/curator.py +62 -0
- package/lattice_brain/graph/discovery.py +76 -0
- package/lattice_brain/graph/ingest.py +37 -7
- package/lattice_brain/graph/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +28 -442
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/retrieval_vector.py +10 -0
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/ingestion.py +9 -106
- package/lattice_brain/ingestion_jobs.py +133 -0
- package/lattice_brain/quality.py +26 -6
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +8 -0
- package/latticeai/api/chat.py +9 -0
- package/latticeai/api/chat_agent_http.py +206 -48
- package/latticeai/api/chat_contracts.py +10 -1
- package/latticeai/api/chat_documents.py +16 -0
- package/latticeai/api/evidence_actions.py +48 -0
- package/latticeai/api/permission_mode.py +61 -0
- package/latticeai/api/project_sessions.py +112 -0
- package/latticeai/api/voice_capture.py +89 -0
- package/latticeai/app_factory.py +53 -0
- package/latticeai/core/agent.py +536 -60
- package/latticeai/core/agent_permission.py +160 -0
- package/latticeai/core/agent_profiles.py +110 -0
- package/latticeai/core/artifact_ledger.py +109 -0
- package/latticeai/core/context_builder.py +101 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/permission_mode.py +296 -0
- package/latticeai/core/project_sessions.py +337 -0
- package/latticeai/core/run_explain.py +421 -0
- package/latticeai/core/run_store.py +7 -0
- package/latticeai/core/workspace_os.py +15 -80
- package/latticeai/core/workspace_review_items.py +107 -0
- package/latticeai/integrations/telegram_bot.py +270 -23
- package/latticeai/runtime/chat_wiring.py +8 -0
- package/latticeai/runtime/context_runtime.py +11 -0
- package/latticeai/runtime/permission_mode_wiring.py +116 -0
- package/latticeai/runtime/router_registration.py +9 -0
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +105 -0
- package/latticeai/services/evidence_actions.py +254 -0
- package/latticeai/services/funnel_metrics.py +111 -16
- package/latticeai/services/local_knowledge.py +26 -0
- package/latticeai/services/memory_service.py +12 -0
- package/latticeai/services/permission_mode_service.py +170 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +129 -4
- package/latticeai/services/voice_capture.py +189 -0
- package/package.json +6 -3
- package/scripts/check_bundle_budget.mjs +11 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +148 -0
- package/scripts/run_sidecar_e2e.mjs +159 -0
- package/skills/meeting_notes/SKILL.md +82 -0
- package/skills/meeting_notes/examples.md +41 -0
- package/skills/meeting_notes/risk.json +9 -0
- package/skills/meeting_notes/schema.json +55 -0
- package/skills/weekly_review/SKILL.md +75 -0
- package/skills/weekly_review/examples.md +39 -0
- package/skills/weekly_review/risk.json +9 -0
- package/skills/weekly_review/schema.json +55 -0
- 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 +37 -29
- package/static/app/assets/Act-BFbpl2_Y.js +1 -0
- package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
- package/static/app/assets/Brain-DGrV0heZ.js +321 -0
- package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
- package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
- package/static/app/assets/Capture-DRaf1zLH.js +1 -0
- package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
- package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
- package/static/app/assets/Library-BVtrSt12.js +1 -0
- package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
- package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
- package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
- package/static/app/assets/System-B4nQDy8a.js +1 -0
- package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
- package/static/app/assets/brain-wVV63_1z.js +1 -0
- package/static/app/assets/button-BTxZDMFJ.js +1 -0
- package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
- package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
- package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
- package/static/app/assets/download-EJS5_O0f.js +1 -0
- package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
- package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
- package/static/app/assets/index-BFtDYI6l.js +10 -0
- package/static/app/assets/index-BY9bpxyx.css +2 -0
- package/static/app/assets/input-DEz5UjBz.js +1 -0
- package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
- package/static/app/assets/primitives-jNrZm2O9.js +1 -0
- package/static/app/assets/search-ku6-_6wY.js +1 -0
- package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
- package/static/app/assets/textarea-DpcQkn75.js +1 -0
- package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
- package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
- package/static/app/assets/users-C0cFMj6o.js +1 -0
- package/static/app/assets/utils-DYf7pqrR.js +7 -0
- package/static/app/assets/workspace-CjthHsLN.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-BCmTU0E2.js +0 -1
- package/static/app/assets/Brain-CTnjox7w.js +0 -321
- package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
- package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
- package/static/app/assets/Capture-B6vBhFa3.js +0 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
- package/static/app/assets/Library-pKCK0_tk.js +0 -1
- package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
- package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
- package/static/app/assets/System-BAEuHqNY.js +0 -1
- package/static/app/assets/index-7FAfYm4v.css +0 -2
- package/static/app/assets/index-DrmOCySv.js +0 -10
- package/static/app/assets/input-gtVCg-ll.js +0 -1
- package/static/app/assets/primitives-CX2Komon.js +0 -1
- package/static/app/assets/shield-alert-BftATuAA.js +0 -1
- package/static/app/assets/textarea-CiMJfOSI.js +0 -1
- package/static/app/assets/utils-SJUNVOj5.js +0 -7
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Review-queue persistence extracted from WorkspaceOSStore (v9.9.6).
|
|
2
|
+
|
|
3
|
+
Workspace-scoped suggestion inbox: automation/trigger runs write drafts here
|
|
4
|
+
for the user to approve/dismiss/snooze. **Persistence only** — the transition
|
|
5
|
+
policy lives in :mod:`latticeai.services.review_queue`.
|
|
6
|
+
|
|
7
|
+
Behaviour-preserving move (review 2026-07-27 P2 #8 "대형 모듈 추가 분해 —
|
|
8
|
+
동작 보존 이동 우선"): the store delegates the same method names to this
|
|
9
|
+
collaborator, following the existing ``WorkspaceRuns`` / ``WorkspaceGraphTrace``
|
|
10
|
+
pattern.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from .timeutil import now_iso as _now
|
|
17
|
+
from .workspace_os_utils import _json_hash, _listify
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WorkspaceReviewItems:
|
|
21
|
+
def __init__(self, store: Any):
|
|
22
|
+
self._store = store
|
|
23
|
+
|
|
24
|
+
def __getattr__(self, name: str) -> Any:
|
|
25
|
+
return getattr(self._store, name)
|
|
26
|
+
|
|
27
|
+
def create_review_item(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
title: str,
|
|
31
|
+
summary: str = "",
|
|
32
|
+
source: str = "workflow_run",
|
|
33
|
+
kind: str = "suggestion",
|
|
34
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
35
|
+
provenance: Optional[Dict[str, Any]] = None,
|
|
36
|
+
user_email: Optional[str] = None,
|
|
37
|
+
workspace_id: Optional[str] = None,
|
|
38
|
+
) -> Dict[str, Any]:
|
|
39
|
+
if not str(title or "").strip():
|
|
40
|
+
raise ValueError("title is required")
|
|
41
|
+
state = self.load_state()
|
|
42
|
+
resolved_workspace = self._resolve_scope(workspace_id, state)
|
|
43
|
+
now = _now()
|
|
44
|
+
item = {
|
|
45
|
+
"id": f"review-{_json_hash([title, source, kind, user_email, now])[:16]}",
|
|
46
|
+
"status": "pending",
|
|
47
|
+
"title": title,
|
|
48
|
+
"summary": summary or "",
|
|
49
|
+
"source": source or "workflow_run",
|
|
50
|
+
"kind": kind or "suggestion",
|
|
51
|
+
"payload": dict(payload or {}),
|
|
52
|
+
"provenance": dict(provenance or {}),
|
|
53
|
+
"snoozed_until": None,
|
|
54
|
+
"user_email": user_email,
|
|
55
|
+
"workspace_id": resolved_workspace,
|
|
56
|
+
"created_at": now,
|
|
57
|
+
"updated_at": now,
|
|
58
|
+
}
|
|
59
|
+
state.setdefault("review_items", []).append(item)
|
|
60
|
+
self.save_state(state)
|
|
61
|
+
self.record_timeline_event(
|
|
62
|
+
"review", "review_item_created",
|
|
63
|
+
{"item_id": item["id"], "source": item["source"], "kind": item["kind"]},
|
|
64
|
+
workspace_id=resolved_workspace,
|
|
65
|
+
)
|
|
66
|
+
return item
|
|
67
|
+
|
|
68
|
+
def list_review_items(
|
|
69
|
+
self, *, workspace_id: Optional[str] = None, user_email: Optional[str] = None,
|
|
70
|
+
source: Optional[str] = None,
|
|
71
|
+
) -> List[Dict[str, Any]]:
|
|
72
|
+
items = self._scoped(_listify(self.load_state().get("review_items")), workspace_id)
|
|
73
|
+
if user_email:
|
|
74
|
+
items = [item for item in items if item.get("user_email") in {None, user_email}]
|
|
75
|
+
if source:
|
|
76
|
+
items = [item for item in items if item.get("source") == source]
|
|
77
|
+
return list(reversed(items))
|
|
78
|
+
|
|
79
|
+
def get_review_item(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
80
|
+
item = next(
|
|
81
|
+
(it for it in _listify(self.load_state().get("review_items")) if it.get("id") == item_id),
|
|
82
|
+
None,
|
|
83
|
+
)
|
|
84
|
+
if item is None or (workspace_id and self._record_workspace(item) != str(workspace_id)):
|
|
85
|
+
raise FileNotFoundError(item_id)
|
|
86
|
+
return item
|
|
87
|
+
|
|
88
|
+
def update_review_item(
|
|
89
|
+
self, item_id: str, *, workspace_id: Optional[str] = None, **fields: Any,
|
|
90
|
+
) -> Dict[str, Any]:
|
|
91
|
+
state = self.load_state()
|
|
92
|
+
item = next((it for it in _listify(state.get("review_items")) if it.get("id") == item_id), None)
|
|
93
|
+
if item is None or (workspace_id and self._record_workspace(item) != str(workspace_id)):
|
|
94
|
+
raise FileNotFoundError(item_id)
|
|
95
|
+
for key, value in fields.items():
|
|
96
|
+
item[key] = value
|
|
97
|
+
item["updated_at"] = _now()
|
|
98
|
+
self.save_state(state)
|
|
99
|
+
self.record_timeline_event(
|
|
100
|
+
"review", "review_item_updated",
|
|
101
|
+
{"item_id": item_id, "status": item.get("status")},
|
|
102
|
+
workspace_id=self._record_workspace(item),
|
|
103
|
+
)
|
|
104
|
+
return item
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["WorkspaceReviewItems"]
|
|
@@ -36,11 +36,13 @@ STATUS_URL = f"{BASE_URL}/status"
|
|
|
36
36
|
MODELS_URL = f"{BASE_URL}/models"
|
|
37
37
|
GRAPH_STATS_URL = f"{BASE_URL}/knowledge-graph/stats"
|
|
38
38
|
UPLOAD_DOC_URL = f"{BASE_URL}/upload/document"
|
|
39
|
+
PROPOSALS_URL = f"{BASE_URL}/api/proposals"
|
|
39
40
|
|
|
40
41
|
AGENT_RESUME_URL = f"{BASE_URL}/agent/resume"
|
|
41
42
|
AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
|
|
42
43
|
|
|
43
|
-
# Pending plan approvals
|
|
44
|
+
# Pending plan approvals keyed by run_id (or legacy context_id).
|
|
45
|
+
# Values: chat_id, models, and the resume credentials (token and/or legacy).
|
|
44
46
|
_bot_pending_plans: dict[str, dict] = {}
|
|
45
47
|
MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
|
|
46
48
|
INVITE_CODE = env_value("LATTICEAI_INVITE_CODE")
|
|
@@ -296,6 +298,9 @@ MAIN_MENU = {
|
|
|
296
298
|
{"text": "🔗 웹 UI 열기", "callback_data": "cmd:web"},
|
|
297
299
|
{"text": "🔌 MCP 도구 목록", "callback_data": "cmd:mcp"},
|
|
298
300
|
],
|
|
301
|
+
[
|
|
302
|
+
{"text": "🗂 변경 제안 검토", "callback_data": "cmd:review"},
|
|
303
|
+
],
|
|
299
304
|
]
|
|
300
305
|
}
|
|
301
306
|
|
|
@@ -664,6 +669,176 @@ def collect_generated_files(agent_data):
|
|
|
664
669
|
files.append((path, target))
|
|
665
670
|
return files
|
|
666
671
|
|
|
672
|
+
def format_grounding_badge(data, *, language: str = "ko") -> str:
|
|
673
|
+
"""Answer-grounding badge line for a `/chat` reply (v9.9.7).
|
|
674
|
+
|
|
675
|
+
Telegram used to show only the answer text, so a reply the Brain could not
|
|
676
|
+
ground looked identical to one built on real sources. This renders exactly
|
|
677
|
+
the verdict the server issued — and reports an absent verdict as unknown
|
|
678
|
+
rather than promoting it to "근거 있음".
|
|
679
|
+
"""
|
|
680
|
+
if not isinstance(data, dict):
|
|
681
|
+
return ""
|
|
682
|
+
grounding = data.get("grounding")
|
|
683
|
+
if not isinstance(grounding, dict):
|
|
684
|
+
return ""
|
|
685
|
+
status = str(grounding.get("status") or "")
|
|
686
|
+
if not status:
|
|
687
|
+
return ""
|
|
688
|
+
korean = not str(language or "ko").startswith("en")
|
|
689
|
+
cited = [
|
|
690
|
+
str(item.get("title") or "").strip()
|
|
691
|
+
for item in (grounding.get("cited") or [])
|
|
692
|
+
if isinstance(item, dict) and str(item.get("title") or "").strip()
|
|
693
|
+
]
|
|
694
|
+
if status == "supported":
|
|
695
|
+
head = "✅ 근거 있음" if korean else "✅ grounded"
|
|
696
|
+
if cited:
|
|
697
|
+
head += " — " + ", ".join(cited[:3])
|
|
698
|
+
return head
|
|
699
|
+
if status in {"unsupported", "no_context"}:
|
|
700
|
+
head = "⚠️ 근거 없음" if korean else "⚠️ not grounded"
|
|
701
|
+
reason = str(grounding.get("reason") or "").strip()
|
|
702
|
+
return f"{head} — {reason}" if reason else head
|
|
703
|
+
return "❔ 근거 확인 불가" if korean else "❔ grounding unknown"
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
async def send_grounding_badge(client, chat_id, data):
|
|
707
|
+
text = format_grounding_badge(data)
|
|
708
|
+
if text:
|
|
709
|
+
await send_message(client, chat_id, text)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def format_proposals(payload) -> list:
|
|
713
|
+
"""`GET /api/proposals` → ``[(item_id, label)]`` for the Review Center.
|
|
714
|
+
|
|
715
|
+
Rows without an id are dropped: an un-actionable row is worse than none.
|
|
716
|
+
"""
|
|
717
|
+
root = payload if isinstance(payload, dict) else {}
|
|
718
|
+
rows = payload if isinstance(payload, list) else (root.get("items") or [])
|
|
719
|
+
out = []
|
|
720
|
+
for raw in rows if isinstance(rows, list) else []:
|
|
721
|
+
if not isinstance(raw, dict):
|
|
722
|
+
continue
|
|
723
|
+
item_id = str(raw.get("id") or "").strip()
|
|
724
|
+
if not item_id:
|
|
725
|
+
continue
|
|
726
|
+
body = raw.get("payload") if isinstance(raw.get("payload"), dict) else {}
|
|
727
|
+
provenance = raw.get("provenance") if isinstance(raw.get("provenance"), dict) else {}
|
|
728
|
+
path = str(body.get("path") or provenance.get("path") or "")
|
|
729
|
+
title = str(raw.get("title") or path or item_id)
|
|
730
|
+
change_class = str(body.get("change_class") or provenance.get("change_class") or "")
|
|
731
|
+
label = title if not change_class else f"{title} ({change_class})"
|
|
732
|
+
out.append((item_id, label))
|
|
733
|
+
return out
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
async def show_review_center(client, chat_id):
|
|
737
|
+
"""Review Center parity (v9.9.7): staged change proposals with inline
|
|
738
|
+
approve/reject, using the same `/api/proposals` surface as the web app."""
|
|
739
|
+
try:
|
|
740
|
+
async with _server_client() as sc:
|
|
741
|
+
res = await sc.get(PROPOSALS_URL, timeout=30.0)
|
|
742
|
+
if res.status_code != 200:
|
|
743
|
+
await send_message(client, chat_id, f"검토함을 불러오지 못했습니다 ({res.status_code})")
|
|
744
|
+
return
|
|
745
|
+
items = format_proposals(res.json())
|
|
746
|
+
except Exception as exc:
|
|
747
|
+
await send_message(client, chat_id, f"검토함 조회 실패: {exc}")
|
|
748
|
+
return
|
|
749
|
+
if not items:
|
|
750
|
+
await send_message(client, chat_id, "🗂 검토할 변경 제안이 없습니다.")
|
|
751
|
+
return
|
|
752
|
+
lines = ["🗂 변경 제안 (승인해야 적용됩니다)"]
|
|
753
|
+
keyboard = []
|
|
754
|
+
for item_id, label in items[:8]:
|
|
755
|
+
lines.append(f"- {label}")
|
|
756
|
+
keyboard.append([
|
|
757
|
+
{"text": f"✅ 승인 — {label}"[:64], "callback_data": f"proposal:approve:{item_id}"[:64]},
|
|
758
|
+
{"text": "❌ 거절"[:64], "callback_data": f"proposal:reject:{item_id}"[:64]},
|
|
759
|
+
])
|
|
760
|
+
if len(items) > 8:
|
|
761
|
+
lines.append(f"… 외 {len(items) - 8}건")
|
|
762
|
+
await send_message(
|
|
763
|
+
client, chat_id, "\n".join(lines),
|
|
764
|
+
reply_markup={"inline_keyboard": keyboard},
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
async def handle_proposal_callback(client, chat_id, data: str):
|
|
769
|
+
"""Apply or reject one staged proposal.
|
|
770
|
+
|
|
771
|
+
A 409 means the target file changed since staging — nothing was written,
|
|
772
|
+
and the user is told exactly that instead of a silent retry.
|
|
773
|
+
"""
|
|
774
|
+
try:
|
|
775
|
+
_, decision, item_id = data.split(":", 2)
|
|
776
|
+
except ValueError:
|
|
777
|
+
return
|
|
778
|
+
if not item_id:
|
|
779
|
+
return
|
|
780
|
+
suffix = "approve" if decision == "approve" else "reject"
|
|
781
|
+
url = f"{PROPOSALS_URL}/{item_id}/{suffix}"
|
|
782
|
+
try:
|
|
783
|
+
async with _server_client() as sc:
|
|
784
|
+
res = await sc.post(url, json={}, timeout=120.0)
|
|
785
|
+
except Exception as exc:
|
|
786
|
+
await send_message(client, chat_id, f"❌ 처리 실패: {exc}")
|
|
787
|
+
return
|
|
788
|
+
if res.status_code == 409:
|
|
789
|
+
await send_message(
|
|
790
|
+
client, chat_id,
|
|
791
|
+
"⚠️ 제안을 만든 뒤 파일이 바뀌어서 적용하지 않았습니다. 아무것도 쓰지 않았습니다.",
|
|
792
|
+
)
|
|
793
|
+
return
|
|
794
|
+
if res.status_code != 200:
|
|
795
|
+
await send_message(client, chat_id, f"❌ 서버 에러 ({res.status_code})")
|
|
796
|
+
return
|
|
797
|
+
if suffix == "approve":
|
|
798
|
+
try:
|
|
799
|
+
applied = res.json().get("path") or item_id
|
|
800
|
+
except Exception:
|
|
801
|
+
applied = item_id
|
|
802
|
+
await send_message(client, chat_id, f"✅ 적용했습니다: {applied}")
|
|
803
|
+
else:
|
|
804
|
+
await send_message(client, chat_id, "🚫 제안을 거절했습니다.")
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def format_run_explanation(agent_data, *, language: str = "ko") -> str:
|
|
808
|
+
"""Plain-language outcome line for a finished agent run (v9.9.6).
|
|
809
|
+
|
|
810
|
+
Telegram used to show only the final message, so a NEEDS_REVIEW run read
|
|
811
|
+
exactly like a success. The server already computes the honest sentence
|
|
812
|
+
(`explanation`); this renders it. Returns "" when there is nothing to add
|
|
813
|
+
— a clean, verified run gets no extra noise.
|
|
814
|
+
"""
|
|
815
|
+
if not isinstance(agent_data, dict):
|
|
816
|
+
return ""
|
|
817
|
+
explanation = agent_data.get("explanation")
|
|
818
|
+
if not isinstance(explanation, dict):
|
|
819
|
+
return ""
|
|
820
|
+
lang = "ko" if str(language or "ko").startswith("ko") else "en"
|
|
821
|
+
|
|
822
|
+
def pick(entry):
|
|
823
|
+
return str(entry.get(lang) or "") if isinstance(entry, dict) else ""
|
|
824
|
+
|
|
825
|
+
details = [pick(item) for item in explanation.get("details") or []]
|
|
826
|
+
details = [line for line in details if line]
|
|
827
|
+
if explanation.get("ok") and not details:
|
|
828
|
+
return ""
|
|
829
|
+
headline = pick(explanation.get("headline"))
|
|
830
|
+
marker = "✅" if explanation.get("ok") else "⚠️"
|
|
831
|
+
lines = [f"{marker} {headline}" if headline else marker]
|
|
832
|
+
lines.extend(f"· {line}" for line in details[:4])
|
|
833
|
+
return "\n".join(lines)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
async def send_run_explanation(client, chat_id, agent_data):
|
|
837
|
+
text = format_run_explanation(agent_data)
|
|
838
|
+
if text:
|
|
839
|
+
await send_message(client, chat_id, text)
|
|
840
|
+
|
|
841
|
+
|
|
667
842
|
def collect_preview_urls(agent_data):
|
|
668
843
|
urls, seen = [], set()
|
|
669
844
|
for step in agent_data.get("steps", []):
|
|
@@ -711,35 +886,65 @@ async def send_generated_files(client, chat_id, generated_files):
|
|
|
711
886
|
|
|
712
887
|
# ── Plan approval (Human-in-the-loop) ────────────────────────────────────────
|
|
713
888
|
|
|
889
|
+
def _approval_pause_id(data: dict) -> str:
|
|
890
|
+
"""Stable key for a paused plan: run_id preferred, legacy context_id fallback."""
|
|
891
|
+
return str(data.get("run_id") or data.get("context_id") or "")
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _is_approval_pause(data: dict) -> bool:
|
|
895
|
+
status = str(data.get("status") or "")
|
|
896
|
+
return status in {"waiting_approval", "awaiting_approval"}
|
|
897
|
+
|
|
898
|
+
|
|
714
899
|
async def send_plan_for_approval(client, chat_id, data: dict) -> None:
|
|
715
|
-
"""Show the agent plan to the user and present Done/Cancel buttons.
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
900
|
+
"""Show the agent plan to the user and present Done/Cancel buttons.
|
|
901
|
+
|
|
902
|
+
Handles both the modern ``awaiting_approval`` (run_id + token) and the
|
|
903
|
+
legacy ``waiting_approval`` / ``context_id`` wire contracts (v9.9.5
|
|
904
|
+
SURFACE_PARITY — Telegram approval flow).
|
|
905
|
+
"""
|
|
906
|
+
pause_id = _approval_pause_id(data)
|
|
907
|
+
if not pause_id:
|
|
908
|
+
await send_message(client, chat_id, "❌ 승인할 계획을 식별할 수 없습니다.")
|
|
909
|
+
return
|
|
910
|
+
plan = data.get("plan", {}) or {}
|
|
911
|
+
goal = plan.get("goal") or (data.get("approval") or {}).get("plan_summary") or ""
|
|
912
|
+
steps = plan.get("steps", []) or data.get("non_auto_steps") or []
|
|
720
913
|
p_model = data.get("planning_model", "current")
|
|
721
914
|
e_model = data.get("executing_model", "current")
|
|
722
915
|
r_model = data.get("reviewing_model", "current")
|
|
916
|
+
approval = data.get("approval") or {}
|
|
917
|
+
expires_at = approval.get("expires_at")
|
|
723
918
|
|
|
724
919
|
lines = ["📋 *플래닝 완료* — 실행 전 확인해주세요\n"]
|
|
725
920
|
if goal:
|
|
726
921
|
lines.append(f"*목표:* {goal}\n")
|
|
727
922
|
for i, step in enumerate(steps, 1):
|
|
728
|
-
|
|
923
|
+
if isinstance(step, dict):
|
|
924
|
+
desc = step.get("description") or step.get("action") or str(step)
|
|
925
|
+
else:
|
|
926
|
+
desc = str(step)
|
|
729
927
|
lines.append(f"{i}. {desc}")
|
|
730
928
|
lines.append(f"\n🧠 플래닝: `{p_model}`")
|
|
731
929
|
lines.append(f"⚙️ 실행: `{e_model}`")
|
|
732
930
|
lines.append(f"🔍 검토: `{r_model}`")
|
|
931
|
+
if expires_at:
|
|
932
|
+
lines.append(f"⏳ 승인 만료: `{expires_at}`")
|
|
733
933
|
|
|
734
|
-
_bot_pending_plans[
|
|
934
|
+
_bot_pending_plans[pause_id] = {
|
|
735
935
|
"chat_id": chat_id,
|
|
936
|
+
"run_id": data.get("run_id") or pause_id,
|
|
937
|
+
"context_id": data.get("context_id"),
|
|
938
|
+
"approval_token": approval.get("token"),
|
|
939
|
+
"legacy": data.get("status") == "waiting_approval" or bool(data.get("context_id")),
|
|
736
940
|
"executing_model": data.get("executing_model"),
|
|
737
941
|
"reviewing_model": data.get("reviewing_model"),
|
|
738
942
|
}
|
|
739
943
|
|
|
944
|
+
# callback_data max 64 bytes — pause ids are token_urlsafe(16) (~22 chars).
|
|
740
945
|
keyboard = {"inline_keyboard": [[
|
|
741
|
-
{"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{
|
|
742
|
-
{"text": "❌ 취소", "callback_data": f"plan:cancel:{
|
|
946
|
+
{"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{pause_id}"},
|
|
947
|
+
{"text": "❌ 취소", "callback_data": f"plan:cancel:{pause_id}"},
|
|
743
948
|
]]}
|
|
744
949
|
await send_message(client, chat_id, "\n".join(lines), reply_markup=keyboard)
|
|
745
950
|
|
|
@@ -749,8 +954,8 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
|
|
|
749
954
|
parts = data.split(":", 2)
|
|
750
955
|
if len(parts) != 3:
|
|
751
956
|
return
|
|
752
|
-
_, action,
|
|
753
|
-
pending = _bot_pending_plans.get(
|
|
957
|
+
_, action, pause_id = parts
|
|
958
|
+
pending = _bot_pending_plans.get(pause_id)
|
|
754
959
|
|
|
755
960
|
# A callback is bound to the chat that received the plan. Even two allowed
|
|
756
961
|
# chats cannot approve or cancel each other's plan by replaying callback
|
|
@@ -758,9 +963,19 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
|
|
|
758
963
|
if pending and int(pending.get("chat_id")) != int(chat_id):
|
|
759
964
|
await send_message(client, chat_id, "❌ 다른 채팅의 작업은 처리할 수 없습니다.")
|
|
760
965
|
return
|
|
761
|
-
pending = _bot_pending_plans.pop(
|
|
966
|
+
pending = _bot_pending_plans.pop(pause_id, None)
|
|
762
967
|
|
|
763
|
-
if action == "cancel"
|
|
968
|
+
if action == "cancel":
|
|
969
|
+
if pending:
|
|
970
|
+
try:
|
|
971
|
+
resume_body = _resume_payload(pending, approved=False)
|
|
972
|
+
async with _server_client() as sc:
|
|
973
|
+
await sc.post(AGENT_RESUME_URL, json=resume_body, timeout=30.0)
|
|
974
|
+
except Exception as exc: # noqa: BLE001 — cancel is best-effort
|
|
975
|
+
logger.warning("telegram cancel resume failed: %s", safe_log_text(exc))
|
|
976
|
+
await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
|
|
977
|
+
return
|
|
978
|
+
if not pending:
|
|
764
979
|
await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
|
|
765
980
|
return
|
|
766
981
|
|
|
@@ -769,22 +984,41 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
|
|
|
769
984
|
|
|
770
985
|
try:
|
|
771
986
|
async with _server_client() as sc:
|
|
772
|
-
res = await sc.post(
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
}, timeout=300.0)
|
|
987
|
+
res = await sc.post(
|
|
988
|
+
AGENT_RESUME_URL,
|
|
989
|
+
json=_resume_payload(pending, approved=True),
|
|
990
|
+
timeout=300.0,
|
|
991
|
+
)
|
|
778
992
|
data_r = res.json() if res.status_code == 200 else {}
|
|
779
993
|
ans = data_r.get("response", f"❌ 서버 에러 ({res.status_code})")
|
|
780
994
|
await send_message(client, chat_id, str(ans))
|
|
781
995
|
if isinstance(data_r, dict):
|
|
996
|
+
await send_run_explanation(client, chat_id, data_r)
|
|
782
997
|
await send_generated_files(client, chat_id, collect_generated_files(data_r))
|
|
783
998
|
await send_preview_links(client, chat_id, collect_preview_urls(data_r))
|
|
784
999
|
except Exception as e:
|
|
785
1000
|
await send_message(client, chat_id, f"❌ 실행 중 오류: {e}")
|
|
786
1001
|
|
|
787
1002
|
|
|
1003
|
+
def _resume_payload(pending: dict, *, approved: bool) -> dict:
|
|
1004
|
+
"""Build /agent/resume body: token path preferred, else legacy context_id."""
|
|
1005
|
+
body: dict = {
|
|
1006
|
+
"approved": approved,
|
|
1007
|
+
"executing_model": pending.get("executing_model"),
|
|
1008
|
+
"reviewing_model": pending.get("reviewing_model"),
|
|
1009
|
+
}
|
|
1010
|
+
token = pending.get("approval_token")
|
|
1011
|
+
run_id = pending.get("run_id")
|
|
1012
|
+
# Unified durable store (9.9.5): token-gated resume works for both the
|
|
1013
|
+
# modern awaiting_approval and the legacy human_in_loop pause.
|
|
1014
|
+
if token and run_id:
|
|
1015
|
+
body["run_id"] = run_id
|
|
1016
|
+
body["approval_token"] = token
|
|
1017
|
+
return body
|
|
1018
|
+
body["context_id"] = pending.get("context_id") or run_id
|
|
1019
|
+
return body
|
|
1020
|
+
|
|
1021
|
+
|
|
788
1022
|
# ── AI request task ───────────────────────────────────────────────────────────
|
|
789
1023
|
|
|
790
1024
|
async def process_ai_request(client, chat_id, user_text, image_data=None):
|
|
@@ -794,8 +1028,8 @@ async def process_ai_request(client, chat_id, user_text, image_data=None):
|
|
|
794
1028
|
data = await ask_ai(client, user_text, image_data, agent_mode=not image_data)
|
|
795
1029
|
logger.info("ask_ai 완료: chat_id=%s result_keys=%s", chat_id, list(data.keys()) if isinstance(data, dict) else type(data))
|
|
796
1030
|
|
|
797
|
-
#
|
|
798
|
-
if isinstance(data, dict) and data
|
|
1031
|
+
# Approval pause (legacy waiting_approval or modern awaiting_approval)
|
|
1032
|
+
if isinstance(data, dict) and _is_approval_pause(data):
|
|
799
1033
|
await send_plan_for_approval(client, chat_id, data)
|
|
800
1034
|
return
|
|
801
1035
|
|
|
@@ -803,7 +1037,12 @@ async def process_ai_request(client, chat_id, user_text, image_data=None):
|
|
|
803
1037
|
if not ans or not str(ans).strip():
|
|
804
1038
|
ans = "⚠️ AI가 답변을 생성하지 못했습니다."
|
|
805
1039
|
await send_message(client, chat_id, str(ans))
|
|
1040
|
+
if isinstance(data, dict):
|
|
1041
|
+
# Recall parity (v9.9.7): the same grounding verdict the web app
|
|
1042
|
+
# badges, never promoted when the server issued none.
|
|
1043
|
+
await send_grounding_badge(client, chat_id, data)
|
|
806
1044
|
if not image_data and isinstance(data, dict):
|
|
1045
|
+
await send_run_explanation(client, chat_id, data)
|
|
807
1046
|
await send_generated_files(client, chat_id, collect_generated_files(data))
|
|
808
1047
|
await send_preview_links(client, chat_id, collect_preview_urls(data))
|
|
809
1048
|
except Exception as e:
|
|
@@ -828,6 +1067,7 @@ HELP_TEXT = """\
|
|
|
828
1067
|
/clear [n] — 기록 정리 (마지막 n건 유지)
|
|
829
1068
|
/web — 웹 UI 링크
|
|
830
1069
|
/mcp — MCP 도구 목록
|
|
1070
|
+
/review — 변경 제안 검토함 (승인/거절)
|
|
831
1071
|
/help — 이 도움말
|
|
832
1072
|
|
|
833
1073
|
/agent <작업> — 멀티 LLM 에이전트 (계획 확인 후 실행)
|
|
@@ -866,6 +1106,8 @@ async def handle_command(client, chat_id, command: str, args: str):
|
|
|
866
1106
|
await send_web_link(client, chat_id)
|
|
867
1107
|
elif cmd == "mcp":
|
|
868
1108
|
await send_mcp_tools(client, chat_id)
|
|
1109
|
+
elif cmd in {"review", "proposals"}:
|
|
1110
|
+
await show_review_center(client, chat_id)
|
|
869
1111
|
elif cmd in {"help", "h"}:
|
|
870
1112
|
await send_message(client, chat_id, HELP_TEXT)
|
|
871
1113
|
elif cmd == "agent":
|
|
@@ -887,7 +1129,7 @@ async def handle_command(client, chat_id, command: str, args: str):
|
|
|
887
1129
|
await send_chat_action(client, chat_id, "typing")
|
|
888
1130
|
data = await ask_ai(client, task_text, agent_mode=True,
|
|
889
1131
|
executing_model=exec_model, reviewing_model=reviewing_model)
|
|
890
|
-
if isinstance(data, dict) and data
|
|
1132
|
+
if isinstance(data, dict) and _is_approval_pause(data):
|
|
891
1133
|
await send_plan_for_approval(client, chat_id, data)
|
|
892
1134
|
else:
|
|
893
1135
|
ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
|
|
@@ -927,6 +1169,8 @@ async def handle_callback_query(client, callback_query):
|
|
|
927
1169
|
await send_web_link(client, chat_id)
|
|
928
1170
|
elif data == "cmd:mcp":
|
|
929
1171
|
await send_mcp_tools(client, chat_id)
|
|
1172
|
+
elif data == "cmd:review":
|
|
1173
|
+
await show_review_center(client, chat_id)
|
|
930
1174
|
elif data == "cmd:menu":
|
|
931
1175
|
await show_menu(client, chat_id)
|
|
932
1176
|
elif data.startswith("model:unload:"):
|
|
@@ -935,6 +1179,9 @@ async def handle_callback_query(client, callback_query):
|
|
|
935
1179
|
elif data.startswith("plan:"):
|
|
936
1180
|
task = asyncio.create_task(handle_plan_callback(client, chat_id, data))
|
|
937
1181
|
task.add_done_callback(_log_task_exception)
|
|
1182
|
+
elif data.startswith("proposal:"):
|
|
1183
|
+
task = asyncio.create_task(handle_proposal_callback(client, chat_id, data))
|
|
1184
|
+
task.add_done_callback(_log_task_exception)
|
|
938
1185
|
|
|
939
1186
|
# ── Main loop ─────────────────────────────────────────────────────────────────
|
|
940
1187
|
|
|
@@ -4,6 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any, Optional
|
|
6
6
|
|
|
7
|
+
from latticeai.runtime.permission_mode_wiring import (
|
|
8
|
+
bind_dispatch_permission_mode,
|
|
9
|
+
resolve_active_permission_mode,
|
|
10
|
+
)
|
|
7
11
|
from latticeai.services.router_context import InteractionRouterContext, ToolRouterContext
|
|
8
12
|
|
|
9
13
|
|
|
@@ -19,6 +23,9 @@ def build_chat_agent_runtime_from_context(
|
|
|
19
23
|
hooks: Any,
|
|
20
24
|
brain_memory: Any,
|
|
21
25
|
) -> Any:
|
|
26
|
+
# Ensure dispatch + agent share the same autonomy dial before the runtime
|
|
27
|
+
# is constructed (process-wide service; data_dir refined later at router mount).
|
|
28
|
+
bind_dispatch_permission_mode()
|
|
22
29
|
return build_agent_runtime(
|
|
23
30
|
model_router=model_router,
|
|
24
31
|
execute_tool=execute_tool,
|
|
@@ -28,6 +35,7 @@ def build_chat_agent_runtime_from_context(
|
|
|
28
35
|
audit=audit,
|
|
29
36
|
hooks=hooks,
|
|
30
37
|
brain_memory=brain_memory,
|
|
38
|
+
permission_mode=resolve_active_permission_mode,
|
|
31
39
|
)
|
|
32
40
|
|
|
33
41
|
|
|
@@ -13,6 +13,7 @@ def build_context_runtime(
|
|
|
13
13
|
gardener: Any,
|
|
14
14
|
require_auth: bool,
|
|
15
15
|
allowed_scopes_for_user: Callable[[Any], Any],
|
|
16
|
+
artifact_ledger: Any = None,
|
|
16
17
|
) -> Dict[str, Any]:
|
|
17
18
|
"""Construct search, brain memory, and context assembly services."""
|
|
18
19
|
|
|
@@ -41,15 +42,25 @@ def build_context_runtime(
|
|
|
41
42
|
allowed = allowed_scopes_for_user(user_email)
|
|
42
43
|
return gardener.get_relevant_context(q, allowed_workspaces=allowed, **kw)
|
|
43
44
|
|
|
45
|
+
# Re-search loop (v9.9.6): files this conversation just produced are a
|
|
46
|
+
# deterministic context section, so "그 파일에 다크모드 넣어줘" works before
|
|
47
|
+
# asynchronous indexing catches up. Optional — absent ledger, absent section.
|
|
48
|
+
if artifact_ledger is None:
|
|
49
|
+
from latticeai.core.artifact_ledger import ArtifactLedger
|
|
50
|
+
|
|
51
|
+
artifact_ledger = ArtifactLedger()
|
|
52
|
+
|
|
44
53
|
context_assembler = ContextAssembler(
|
|
45
54
|
memory_recall=memory_service.recall,
|
|
46
55
|
hybrid_search=scoped_hybrid_search,
|
|
47
56
|
notes_context=scoped_notes_context,
|
|
57
|
+
recent_artifacts=artifact_ledger.recent,
|
|
48
58
|
)
|
|
49
59
|
|
|
50
60
|
return {
|
|
51
61
|
"SEARCH_SERVICE": search_service,
|
|
52
62
|
"BRAIN_MEMORY": brain_memory,
|
|
53
63
|
"CONTEXT_ASSEMBLER": context_assembler,
|
|
64
|
+
"ARTIFACT_LEDGER": artifact_ledger,
|
|
54
65
|
"_scoped_hybrid_search": scoped_hybrid_search,
|
|
55
66
|
}
|