ltcai 11.2.0 → 11.5.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.
Files changed (253) hide show
  1. package/README.md +50 -53
  2. package/docs/CHANGELOG.md +87 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +6 -2
  8. package/docs/PERMISSION_MODE.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +2 -2
  12. package/docs/v11.3.0_PLAN.md +202 -0
  13. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +181 -0
  14. package/docs/v11.5.0_RUST_COMPLETE_PLAN.md +145 -0
  15. package/lattice_brain/__init__.py +1 -1
  16. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  17. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  18. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  19. package/lattice_brain/graph/_kg_common/text.py +479 -0
  20. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  21. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  22. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  23. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  24. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  25. package/lattice_brain/graph/projection/__init__.py +42 -0
  26. package/lattice_brain/graph/projection/curation.py +500 -0
  27. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
  28. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  29. package/lattice_brain/graph/retrieval/context.py +197 -0
  30. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  31. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  32. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  33. package/lattice_brain/graph/retrieval/signals.py +95 -0
  34. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  35. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  36. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  37. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  38. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  39. package/lattice_brain/ingestion/__init__.py +130 -0
  40. package/lattice_brain/ingestion/_contract.py +90 -0
  41. package/lattice_brain/ingestion/constants.py +127 -0
  42. package/lattice_brain/ingestion/folder_scan.py +57 -0
  43. package/lattice_brain/ingestion/folders.py +258 -0
  44. package/lattice_brain/ingestion/hashing.py +26 -0
  45. package/lattice_brain/ingestion/jobs_api.py +107 -0
  46. package/lattice_brain/ingestion/models.py +80 -0
  47. package/lattice_brain/ingestion/pipeline.py +486 -0
  48. package/lattice_brain/ingestion/quality.py +209 -0
  49. package/lattice_brain/ingestion/routing.py +295 -0
  50. package/lattice_brain/multimodal/__init__.py +164 -0
  51. package/lattice_brain/multimodal/audio.py +77 -0
  52. package/lattice_brain/multimodal/common.py +118 -0
  53. package/lattice_brain/multimodal/images.py +498 -0
  54. package/lattice_brain/multimodal/ports.py +169 -0
  55. package/lattice_brain/multimodal/video.py +410 -0
  56. package/lattice_brain/portability/__init__.py +90 -0
  57. package/lattice_brain/portability/_contract.py +42 -0
  58. package/lattice_brain/portability/backups.py +338 -0
  59. package/lattice_brain/portability/bundles.py +136 -0
  60. package/lattice_brain/portability/constants.py +93 -0
  61. package/lattice_brain/portability/fsops.py +138 -0
  62. package/lattice_brain/portability/service.py +41 -0
  63. package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
  64. package/lattice_brain/runtime/__init__.py +1 -1
  65. package/lattice_brain/runtime/multi_agent.py +1 -1
  66. package/latticeai/__init__.py +1 -1
  67. package/latticeai/api/chronicle.py +63 -0
  68. package/latticeai/api/index_jobs.py +145 -0
  69. package/latticeai/core/agent/__init__.py +93 -0
  70. package/latticeai/core/agent/_contract.py +79 -0
  71. package/latticeai/core/agent/context.py +57 -0
  72. package/latticeai/core/agent/deps.py +125 -0
  73. package/latticeai/core/agent/execution.py +622 -0
  74. package/latticeai/core/agent/planning.py +145 -0
  75. package/latticeai/core/agent/recovery.py +157 -0
  76. package/latticeai/core/agent/runtime.py +210 -0
  77. package/latticeai/core/agent/verification.py +231 -0
  78. package/latticeai/core/embedding_providers/__init__.py +151 -0
  79. package/latticeai/core/embedding_providers/base.py +199 -0
  80. package/latticeai/core/embedding_providers/captions.py +162 -0
  81. package/latticeai/core/embedding_providers/profiles.py +126 -0
  82. package/latticeai/core/embedding_providers/text.py +350 -0
  83. package/latticeai/core/embedding_providers/vision.py +352 -0
  84. package/latticeai/core/file_generation/__init__.py +115 -0
  85. package/latticeai/core/file_generation/bundles.py +76 -0
  86. package/latticeai/core/file_generation/extraction.py +154 -0
  87. package/latticeai/core/file_generation/inference.py +235 -0
  88. package/latticeai/core/file_generation/orchestration.py +152 -0
  89. package/latticeai/core/file_generation/prompting.py +117 -0
  90. package/latticeai/core/file_generation/repair.py +114 -0
  91. package/latticeai/core/file_generation/sanitize.py +61 -0
  92. package/latticeai/core/file_generation/validation.py +201 -0
  93. package/latticeai/core/legacy_compatibility.py +1 -1
  94. package/latticeai/core/marketplace.py +1 -1
  95. package/latticeai/core/messages.py +14 -0
  96. package/latticeai/core/workspace_os_constants.py +1 -1
  97. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  98. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  99. package/latticeai/integrations/telegram_bot/config.py +86 -0
  100. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  101. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  102. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  103. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  104. package/latticeai/models/router/__init__.py +88 -0
  105. package/latticeai/models/router/_contract.py +66 -0
  106. package/latticeai/models/router/branding.py +56 -0
  107. package/latticeai/models/router/catalog.py +69 -0
  108. package/latticeai/models/router/documents.py +199 -0
  109. package/latticeai/models/router/errors.py +37 -0
  110. package/latticeai/models/router/generation.py +258 -0
  111. package/latticeai/models/router/loading.py +291 -0
  112. package/latticeai/models/router/local_models.py +85 -0
  113. package/latticeai/models/router/registry.py +147 -0
  114. package/latticeai/runtime/build_phases/__init__.py +82 -0
  115. package/latticeai/runtime/build_phases/features.py +421 -0
  116. package/latticeai/runtime/build_phases/foundation.py +555 -0
  117. package/latticeai/runtime/build_phases/web.py +492 -0
  118. package/latticeai/runtime/runtime_context.py +1 -0
  119. package/latticeai/services/architecture_readiness.py +48 -19
  120. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  121. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  122. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  123. package/latticeai/services/brain_intelligence/constants.py +47 -0
  124. package/latticeai/services/brain_intelligence/digest.py +258 -0
  125. package/latticeai/services/brain_intelligence/health.py +331 -0
  126. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  127. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  128. package/latticeai/services/brain_intelligence/service.py +48 -0
  129. package/latticeai/services/chronicle.py +557 -0
  130. package/latticeai/services/memory_service/__init__.py +52 -0
  131. package/latticeai/services/memory_service/_contract.py +100 -0
  132. package/latticeai/services/memory_service/brief.py +431 -0
  133. package/latticeai/services/memory_service/constants.py +57 -0
  134. package/latticeai/services/memory_service/maintenance.py +138 -0
  135. package/latticeai/services/memory_service/manager.py +186 -0
  136. package/latticeai/services/memory_service/proof.py +136 -0
  137. package/latticeai/services/memory_service/recall.py +225 -0
  138. package/latticeai/services/memory_service/service.py +48 -0
  139. package/latticeai/services/memory_service/stores.py +110 -0
  140. package/latticeai/services/model_runtime/__init__.py +322 -0
  141. package/latticeai/services/model_runtime/cloud.py +87 -0
  142. package/latticeai/services/model_runtime/download.py +282 -0
  143. package/latticeai/services/model_runtime/engines.py +341 -0
  144. package/latticeai/services/model_runtime/loading.py +178 -0
  145. package/latticeai/services/model_runtime/service.py +129 -0
  146. package/latticeai/services/model_runtime/state.py +131 -0
  147. package/latticeai/services/model_runtime/status.py +255 -0
  148. package/latticeai/services/product_readiness.py +15 -7
  149. package/latticeai/setup/wizard/__init__.py +126 -0
  150. package/latticeai/setup/wizard/catalog.py +172 -0
  151. package/latticeai/setup/wizard/detect.py +323 -0
  152. package/latticeai/setup/wizard/install.py +348 -0
  153. package/latticeai/setup/wizard/paths.py +168 -0
  154. package/latticeai/setup/wizard/plans.py +74 -0
  155. package/latticeai/setup/wizard/recommend.py +320 -0
  156. package/package.json +6 -2
  157. package/scripts/bump_version.py +14 -0
  158. package/scripts/capture_release_evidence.mjs +33 -21
  159. package/scripts/check_current_release_docs.mjs +1 -1
  160. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  161. package/scripts/check_max_file_lines.mjs +102 -0
  162. package/scripts/check_release_evidence_bound.mjs +30 -15
  163. package/scripts/check_screenshot_pixel_delta.py +34 -4
  164. package/scripts/check_server_i18n.mjs +2 -0
  165. package/scripts/chunking_parity_corpus.py +449 -0
  166. package/scripts/generate_agent_parity_fixtures.py +752 -0
  167. package/scripts/generate_chunking_parity_fixtures.py +259 -0
  168. package/scripts/generate_rust_parity_fixtures.py +997 -0
  169. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  170. package/scripts/release_screen_claims.json +42 -2
  171. package/src-tauri/Cargo.lock +404 -3
  172. package/src-tauri/Cargo.toml +13 -1
  173. package/src-tauri/src/backend.rs +460 -0
  174. package/src-tauri/src/folder.rs +33 -0
  175. package/src-tauri/src/main.rs +109 -399
  176. package/src-tauri/src/topology.rs +356 -0
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +41 -37
  179. package/static/app/assets/Act-CWnxSCgN.js +1 -0
  180. package/static/app/assets/AdminConsole-BEQYU6kF.js +1 -0
  181. package/static/app/assets/{Brain-tuhI4sOC.js → Brain-DWu1BhFg.js} +2 -2
  182. package/static/app/assets/BrainHome-95Hilr9R.js +2 -0
  183. package/static/app/assets/BrainSignals-QdeqCpAF.js +1 -0
  184. package/static/app/assets/Capture-BHpCxnzb.js +1 -0
  185. package/static/app/assets/Chronicle-B4xYKoed.js +1 -0
  186. package/static/app/assets/CommandPalette-BVXnttSz.js +1 -0
  187. package/static/app/assets/Library-DgYcHome.js +1 -0
  188. package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-CrJLDbf7.js} +1 -1
  189. package/static/app/assets/ProductFlow-DFlScKoJ.js +1 -0
  190. package/static/app/assets/ReviewCard-Cy5f48Pj.js +3 -0
  191. package/static/app/assets/System-NF8IfhTa.js +1 -0
  192. package/static/app/assets/arrow-left-DwkSYrjR.js +1 -0
  193. package/static/app/assets/{bot-Cia42c2h.js → bot-CucuhLhm.js} +1 -1
  194. package/static/app/assets/brain-BBnSryW_.js +1 -0
  195. package/static/app/assets/{button-2j2Ijzgq.js → button-C2GUj2Ai.js} +1 -1
  196. package/static/app/assets/circle-check-CxOVPwYq.js +1 -0
  197. package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-CbkWzBmG.js} +1 -1
  198. package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-7lEaqHdJ.js} +1 -1
  199. package/static/app/assets/{cpu-k4awryFq.js → cpu-DAlCXlIy.js} +1 -1
  200. package/static/app/assets/{download-DFbLJ_ig.js → download-RNhuuJwh.js} +1 -1
  201. package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CLW4odzM.js} +1 -1
  202. package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-NKEiDIAJ.js} +1 -1
  203. package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
  204. package/static/app/assets/index-DMurvUuR.js +10 -0
  205. package/static/app/assets/input-D2UhPC1X.js +1 -0
  206. package/static/app/assets/link-2-6amKbP_P.js +1 -0
  207. package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-Cu9TZtdR.js} +1 -1
  208. package/static/app/assets/primitives-gPsccucr.js +1 -0
  209. package/static/app/assets/search-Cj_TKk_2.js +1 -0
  210. package/static/app/assets/{share-2-BH1M-WNi.js → share-2-Bau7KkPq.js} +1 -1
  211. package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-BufNYypi.js} +1 -1
  212. package/static/app/assets/{textarea-CCWbUfFB.js → textarea-BQnVWhYs.js} +1 -1
  213. package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-B3_w60si.js} +1 -1
  214. package/static/app/assets/useMutation-BHhCflT6.js +1 -0
  215. package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-rBWfI-5t.js} +1 -1
  216. package/static/app/assets/utils-V_5-wxr5.js +4 -0
  217. package/static/app/assets/workspace-K1zjYUHj.js +1 -0
  218. package/static/app/index.html +4 -4
  219. package/static/sw.js +1 -1
  220. package/lattice_brain/graph/_kg_common.py +0 -1331
  221. package/lattice_brain/graph/discovery_index.py +0 -1141
  222. package/lattice_brain/graph/retrieval.py +0 -1120
  223. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  224. package/lattice_brain/ingestion.py +0 -1525
  225. package/lattice_brain/multimodal.py +0 -1258
  226. package/latticeai/core/agent.py +0 -1465
  227. package/latticeai/core/embedding_providers.py +0 -1196
  228. package/latticeai/core/file_generation.py +0 -1047
  229. package/latticeai/integrations/telegram_bot.py +0 -1390
  230. package/latticeai/models/router.py +0 -1007
  231. package/latticeai/runtime/build_phases.py +0 -1450
  232. package/latticeai/services/brain_intelligence.py +0 -1083
  233. package/latticeai/services/memory_service.py +0 -1177
  234. package/latticeai/services/model_runtime.py +0 -1281
  235. package/latticeai/setup/wizard.py +0 -1310
  236. package/static/app/assets/Act-AWf0SAKp.js +0 -1
  237. package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
  238. package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
  239. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
  240. package/static/app/assets/Capture-CqOSzyPr.js +0 -1
  241. package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
  242. package/static/app/assets/Library-CX-bbhmK.js +0 -1
  243. package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
  244. package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
  245. package/static/app/assets/System-Bu2t5hn1.js +0 -1
  246. package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
  247. package/static/app/assets/brain-DJMoqrwx.js +0 -1
  248. package/static/app/assets/index-BpYkzcVm.js +0 -10
  249. package/static/app/assets/input-DSlJJxRs.js +0 -1
  250. package/static/app/assets/primitives-BCx6TvfG.js +0 -1
  251. package/static/app/assets/search-Cgy8cCFJ.js +0 -1
  252. package/static/app/assets/utils-zqPZJxdx.js +0 -4
  253. package/static/app/assets/workspace-DXTihhfU.js +0 -1
@@ -1,1390 +0,0 @@
1
- import asyncio
2
- import base64
3
- import json
4
- import logging
5
- import os
6
- import socket
7
- import tempfile
8
- import zipfile
9
- from pathlib import Path
10
- from typing import Any, Dict
11
-
12
- import httpx
13
-
14
- from latticeai.cli.runtime import _load_env_file
15
- from latticeai.core.io_utils import atomic_write_json
16
- from latticeai.core.logging_safety import install_sensitive_log_filter, safe_log_text
17
- from latticeai.core.quiet import quiet
18
-
19
- install_sensitive_log_filter()
20
-
21
- def load_env_file(path=".env"):
22
- # Single source of truth: shared with ltcai_cli via latticeai.cli.runtime.
23
- _load_env_file(Path(path))
24
-
25
- load_env_file()
26
-
27
- def env_value(primary: str, default: str = "") -> str:
28
- return os.getenv(primary) or default
29
-
30
- TOKEN = env_value("LATTICEAI_TELEGRAM_BOT_TOKEN")
31
- API_URL = f"https://api.telegram.org/bot{TOKEN}"
32
- SERVER_PORT = int(env_value("LATTICEAI_SERVER_PORT", "4825"))
33
- BASE_URL = env_value("LATTICEAI_BASE_URL", env_value("LATTICEAI_SERVER_URL", f"http://127.0.0.1:{SERVER_PORT}")).rstrip("/")
34
- CHAT_URL = f"{BASE_URL}/chat"
35
- AGENT_URL = f"{BASE_URL}/agent"
36
- MCP_TOOLS_URL = f"{BASE_URL}/mcp/tools"
37
- HISTORY_URL = f"{BASE_URL}/history"
38
- STATUS_URL = f"{BASE_URL}/status"
39
- MODELS_URL = f"{BASE_URL}/models"
40
- GRAPH_STATS_URL = f"{BASE_URL}/knowledge-graph/stats"
41
- UPLOAD_DOC_URL = f"{BASE_URL}/upload/document"
42
- PROPOSALS_URL = f"{BASE_URL}/api/proposals"
43
-
44
- AGENT_RESUME_URL = f"{BASE_URL}/agent/resume"
45
- AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
46
-
47
- # Pending plan approvals keyed by run_id (or legacy context_id).
48
- # Values: chat_id, models, and the resume credentials (token and/or legacy).
49
- _bot_pending_plans: dict[str, dict] = {}
50
- MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
51
- INVITE_CODE = env_value("LATTICEAI_INVITE_CODE")
52
- PUBLIC_WEB_URL = env_value("LATTICEAI_PUBLIC_URL")
53
- DATA_DIR = Path(env_value("LATTICEAI_DATA_DIR", str(Path.home() / ".ltcai")))
54
- DATA_DIR.mkdir(parents=True, exist_ok=True)
55
- CHAT_IDS_FILE = Path(env_value("LATTICEAI_TELEGRAM_CHATS_FILE", str(DATA_DIR / "telegram_chats.json")))
56
-
57
- logging.basicConfig(level=logging.INFO)
58
- logger = logging.getLogger(__name__)
59
-
60
- # ── Server session auth ───────────────────────────────────────────────────────
61
-
62
- def _get_server_session() -> str:
63
- """Return only the explicitly provisioned bot-to-server token.
64
-
65
- Web sessions are hashed at rest and belong to interactive users. Scanning
66
- ``sessions.json`` cannot recover a usable token and would couple the bot to
67
- whichever administrator happened to be logged in most recently.
68
- """
69
- return env_value("LATTICEAI_SERVER_SESSION_TOKEN").strip()
70
-
71
- def _server_client(**kwargs) -> httpx.AsyncClient:
72
- """Return a server client authenticated by an explicit bearer capability."""
73
- token = _get_server_session()
74
- if not token:
75
- raise RuntimeError(
76
- "LATTICEAI_SERVER_SESSION_TOKEN is required for Telegram server API calls."
77
- )
78
- headers = dict(kwargs.pop("headers", {}) or {})
79
- headers["Authorization"] = f"Bearer {token}"
80
- return httpx.AsyncClient(headers=headers, **kwargs)
81
-
82
- # ── Chat ID registry ─────────────────────────────────────────────────────────
83
-
84
- def parse_allowed_chat_ids(raw: str) -> frozenset[int]:
85
- """Parse a comma-separated Telegram chat-id allowlist."""
86
- allowed: set[int] = set()
87
- for value in str(raw or "").split(","):
88
- value = value.strip()
89
- if not value:
90
- continue
91
- try:
92
- allowed.add(int(value))
93
- except ValueError:
94
- logger.warning(
95
- "Invalid Telegram chat id ignored in allowlist: %r",
96
- safe_log_text(value),
97
- )
98
- return frozenset(allowed)
99
-
100
-
101
- def allowed_chat_ids() -> frozenset[int]:
102
- """Return the configured allowlist; missing configuration denies all."""
103
- return parse_allowed_chat_ids(env_value("LATTICEAI_TELEGRAM_ALLOWED_CHAT_IDS"))
104
-
105
-
106
- def is_chat_allowed(chat_id) -> bool:
107
- try:
108
- return int(chat_id) in allowed_chat_ids()
109
- except (TypeError, ValueError):
110
- return False
111
-
112
-
113
- def load_chat_ids():
114
- try:
115
- if CHAT_IDS_FILE.exists():
116
- data = json.loads(CHAT_IDS_FILE.read_text(encoding="utf-8"))
117
- return {int(cid) for cid in data.get("chat_ids", [])}
118
- except Exception as e:
119
- logger.error("텔레그램 채팅 목록 로드 실패: %s", safe_log_text(e))
120
- return set()
121
-
122
- def save_chat_ids(chat_ids):
123
- try:
124
- atomic_write_json(CHAT_IDS_FILE, {"chat_ids": sorted(chat_ids)})
125
- except Exception as e:
126
- logger.error("텔레그램 채팅 목록 저장 실패: %s", safe_log_text(e))
127
-
128
- def register_chat_id(chat_id):
129
- if not is_chat_allowed(chat_id):
130
- logger.warning(
131
- "허용되지 않은 텔레그램 채팅 등록 차단: %s",
132
- safe_log_text(chat_id),
133
- )
134
- return False
135
- chat_id = int(chat_id)
136
- chat_ids = load_chat_ids()
137
- if chat_id not in chat_ids:
138
- chat_ids.add(chat_id)
139
- save_chat_ids(chat_ids)
140
- logger.info("텔레그램 웹 미러링 대상 등록: %s", chat_id)
141
- return True
142
-
143
- # ── Telegram API helpers ──────────────────────────────────────────────────────
144
-
145
- async def send_message(client, chat_id, text, reply_markup=None):
146
- url = f"{API_URL}/sendMessage"
147
- try:
148
- chunks = [text[i:i+3900] for i in range(0, len(text), 3900)] or [""]
149
- for i, chunk in enumerate(chunks):
150
- payload = {"chat_id": chat_id, "text": chunk}
151
- if reply_markup and i == len(chunks) - 1:
152
- payload["reply_markup"] = reply_markup
153
- await client.post(url, json=payload)
154
- except Exception as e:
155
- logger.error("메시지 전송 실패: %s", safe_log_text(e))
156
-
157
- async def send_photo(client, chat_id, file_path: Path, caption: str = ""):
158
- url = f"{API_URL}/sendPhoto"
159
- try:
160
- # Read on a worker thread: a screenshot is megabytes, and reading it
161
- # inline stalled the bot's poll loop (and everything else on the loop)
162
- # for the length of the read.
163
- blob = await asyncio.to_thread(Path(file_path).read_bytes)
164
- res = await client.post(url, data={"chat_id": str(chat_id), "caption": caption[:1024]},
165
- files={"photo": (file_path.name, blob)}, timeout=60.0)
166
- if res.status_code != 200:
167
- await send_message(client, chat_id, f"사진 전송 실패 ({res.status_code})")
168
- except Exception as e:
169
- logger.error("사진 전송 실패: %s", safe_log_text(e))
170
- await send_message(client, chat_id, f"사진 전송 오류: {safe_log_text(e)}")
171
-
172
- async def send_document(client, chat_id, file_path, caption=None, filename=None):
173
- url = f"{API_URL}/sendDocument"
174
- try:
175
- blob = await asyncio.to_thread(Path(file_path).read_bytes)
176
- res = await client.post(
177
- url,
178
- data={"chat_id": str(chat_id), **({"caption": caption[:1024]} if caption else {})},
179
- files={"document": (filename or Path(file_path).name, blob)},
180
- timeout=300.0,
181
- )
182
- if res.status_code != 200:
183
- logger.error("파일 전송 실패 (%s): %s", res.status_code, safe_log_text(res.text))
184
- except Exception as e:
185
- logger.error("파일 전송 실패: %s", safe_log_text(e))
186
-
187
- async def send_chat_action(client, chat_id, action="typing"):
188
- try:
189
- await client.post(f"{API_URL}/sendChatAction", json={"chat_id": chat_id, "action": action})
190
- except Exception:
191
- quiet()
192
-
193
- async def answer_callback(client, callback_query_id, text=""):
194
- try:
195
- await client.post(f"{API_URL}/answerCallbackQuery",
196
- json={"callback_query_id": callback_query_id, "text": text})
197
- except Exception:
198
- quiet()
199
-
200
- async def edit_message(client, chat_id, message_id, text, reply_markup=None):
201
- try:
202
- payload = {"chat_id": chat_id, "message_id": message_id, "text": text}
203
- if reply_markup:
204
- payload["reply_markup"] = reply_markup
205
- await client.post(f"{API_URL}/editMessageText", json=payload)
206
- except Exception:
207
- quiet()
208
-
209
- # ── Network helpers ───────────────────────────────────────────────────────────
210
-
211
- def get_lan_ip():
212
- try:
213
- with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
214
- s.connect(("8.8.8.8", 80))
215
- ip = s.getsockname()[0]
216
- if not ip.startswith("127."):
217
- return ip
218
- except OSError:
219
- quiet()
220
- try:
221
- hostname = socket.gethostname()
222
- for ip in socket.gethostbyname_ex(hostname)[2]:
223
- if not ip.startswith("127."):
224
- return ip
225
- except OSError:
226
- quiet()
227
- return "127.0.0.1"
228
-
229
- def get_web_url():
230
- if PUBLIC_WEB_URL:
231
- return PUBLIC_WEB_URL.rstrip("/")
232
- base = f"http://{get_lan_ip()}:{SERVER_PORT}/"
233
- return f"{base}?code={INVITE_CODE}" if INVITE_CODE else base
234
-
235
- def get_graph_url():
236
- if PUBLIC_WEB_URL:
237
- return f"{PUBLIC_WEB_URL.rstrip('/')}/graph"
238
- return f"http://{get_lan_ip()}:{SERVER_PORT}/graph"
239
-
240
- # ── Broadcast (web → telegram mirror) ────────────────────────────────────────
241
-
242
- async def broadcast_web_chat(role, text):
243
- if not TOKEN:
244
- return
245
- # Old releases registered every sender. Intersect persisted recipients
246
- # with the current allowlist so stale files cannot keep receiving mirrors.
247
- allowed = allowed_chat_ids()
248
- chat_ids = load_chat_ids() & set(allowed)
249
- if not chat_ids:
250
- return
251
- label = "사용자" if role == "user" else "Lattice AI"
252
- message = f"[Web] {label}\n{text}"
253
- async with httpx.AsyncClient() as client:
254
- for chat_id in chat_ids:
255
- await send_message(client, chat_id, message)
256
-
257
- # ── Polling ───────────────────────────────────────────────────────────────────
258
-
259
- async def get_updates(client, offset=None):
260
- url = f"{API_URL}/getUpdates?timeout=30"
261
- if offset:
262
- url += f"&offset={offset}"
263
- try:
264
- res = await client.get(url, timeout=35)
265
- return res.json()
266
- except Exception:
267
- return None
268
-
269
- # ── File download ─────────────────────────────────────────────────────────────
270
-
271
- async def download_telegram_file(client, file_id) -> bytes | None:
272
- try:
273
- res = await client.get(f"{API_URL}/getFile?file_id={file_id}")
274
- file_path = res.json().get("result", {}).get("file_path")
275
- if not file_path:
276
- return None
277
- dl = await client.get(f"https://api.telegram.org/file/bot{TOKEN}/{file_path}")
278
- return dl.content if dl.status_code == 200 else None
279
- except Exception as e:
280
- logger.error("파일 다운로드 실패: %s", safe_log_text(e))
281
- return None
282
-
283
- async def download_as_base64(client, file_id) -> str | None:
284
- data = await download_telegram_file(client, file_id)
285
- return base64.b64encode(data).decode() if data else None
286
-
287
- # ── Main menu ─────────────────────────────────────────────────────────────────
288
-
289
- MAIN_MENU = {
290
- "inline_keyboard": [
291
- [
292
- {"text": "📊 서버 상태", "callback_data": "cmd:status"},
293
- {"text": "🧠 현재 모델", "callback_data": "cmd:model"},
294
- ],
295
- [
296
- {"text": "🕸 Knowledge Graph", "callback_data": "cmd:graph"},
297
- {"text": "📸 스크린샷", "callback_data": "cmd:screenshot"},
298
- ],
299
- [
300
- {"text": "📜 최근 대화 5건", "callback_data": "cmd:history"},
301
- {"text": "🗑 기록 정리", "callback_data": "cmd:clear"},
302
- ],
303
- [
304
- {"text": "🔗 웹 UI 열기", "callback_data": "cmd:web"},
305
- {"text": "🔌 MCP 도구 목록", "callback_data": "cmd:mcp"},
306
- ],
307
- [
308
- {"text": "🗂 변경 제안 검토", "callback_data": "cmd:review"},
309
- ],
310
- ]
311
- }
312
-
313
- async def show_menu(client, chat_id):
314
- await send_message(client, chat_id, "📱 Lattice AI 원격 제어 메뉴입니다.", reply_markup=MAIN_MENU)
315
-
316
- # ── Server status ─────────────────────────────────────────────────────────────
317
-
318
- async def _mac_ram_used_gb() -> str:
319
- try:
320
- vm_proc = await asyncio.create_subprocess_exec(
321
- "vm_stat", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL
322
- )
323
- vm_out, _ = await vm_proc.communicate()
324
- lines = vm_out.decode().splitlines()
325
-
326
- # Parse page size from header line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
327
- page_size = 4096
328
- if lines:
329
- import re
330
- m = re.search(r"page size of (\d+) bytes", lines[0])
331
- if m:
332
- page_size = int(m.group(1))
333
-
334
- stats = {}
335
- for line in lines[1:]:
336
- if ":" in line:
337
- k, _, v = line.partition(":")
338
- try:
339
- stats[k.strip()] = int(v.strip().rstrip(".")) * page_size
340
- except ValueError:
341
- quiet()
342
-
343
- used = stats.get("Pages active", 0) + stats.get("Pages wired down", 0)
344
-
345
- mem_proc = await asyncio.create_subprocess_exec(
346
- "sysctl", "-n", "hw.memsize", stdout=asyncio.subprocess.PIPE
347
- )
348
- mem_out, _ = await mem_proc.communicate()
349
- total = int(mem_out.strip())
350
- return f"{used/1e9:.1f} GB / {total/1e9:.0f} GB"
351
- except Exception:
352
- return "N/A"
353
-
354
- async def show_status(client, chat_id):
355
- await send_chat_action(client, chat_id, "typing")
356
- try:
357
- async with _server_client() as lc:
358
- res = await lc.get(STATUS_URL, timeout=5.0)
359
- data = res.json() if res.status_code == 200 else {}
360
- except Exception:
361
- data = {}
362
-
363
- ram = await _mac_ram_used_gb()
364
- model = data.get("loaded_model") or "없음"
365
- mode = data.get("mode") or "unknown"
366
- state = "🟢 온라인" if data.get("status") == "online" else "🔴 오프라인"
367
-
368
- text = (
369
- f"📊 Lattice AI 서버 상태\n"
370
- f"상태: {state}\n"
371
- f"모드: {mode}\n"
372
- f"모델: {model}\n"
373
- f"RAM: {ram}"
374
- )
375
- await send_message(client, chat_id, text)
376
-
377
- # ── Model info & unload ───────────────────────────────────────────────────────
378
-
379
- async def show_model_info(client, chat_id):
380
- await send_chat_action(client, chat_id, "typing")
381
- try:
382
- async with _server_client() as lc:
383
- res = await lc.get(MODELS_URL, timeout=5.0)
384
- data = res.json() if res.status_code == 200 else {}
385
- except Exception:
386
- data = {}
387
-
388
- current = data.get("current") or "없음"
389
- loaded = data.get("loaded") or []
390
- loaded_str = "\n".join(f" - {m}" for m in loaded) if loaded else " 없음"
391
- text = f"🧠 현재 모델: {current}\n\n로드된 모델:\n{loaded_str}"
392
-
393
- markup = None
394
- if loaded:
395
- markup = {
396
- "inline_keyboard": [
397
- [{"text": f"🗑 {m} 언로드", "callback_data": f"model:unload:{m}"}]
398
- for m in loaded
399
- ] + [[{"text": "↩ 메뉴로", "callback_data": "cmd:menu"}]]
400
- }
401
- await send_message(client, chat_id, text, reply_markup=markup)
402
-
403
- def _unload_all_report(results: list[tuple[str, int]]) -> str:
404
- """Report an unload-all run from the statuses the server actually returned."""
405
- failed = [(mid, code) for mid, code in results if code != 200]
406
- if not failed:
407
- return "✅ 모든 모델 언로드 완료. RAM이 해제되었습니다."
408
- detail = ", ".join(f"{mid} ({code})" for mid, code in failed)
409
- return (
410
- f"일부 모델 언로드 실패: {detail}\n"
411
- f"성공 {len(results) - len(failed)}개 / 실패 {len(failed)}개"
412
- )
413
-
414
- async def do_unload_model(client, chat_id, model_id: str = ""):
415
- await send_chat_action(client, chat_id, "typing")
416
- try:
417
- results: list[tuple[str, int]] | None = None
418
- async with _server_client() as lc:
419
- if model_id:
420
- res = await lc.delete(f"{BASE_URL}/models/unload/{model_id}", timeout=15.0)
421
- else:
422
- # Unload all: keep every delete's real status. Discarding them
423
- # for a synthesized 200 reported "모든 모델 언로드 완료" even
424
- # when a model refused to unload.
425
- res = await lc.get(MODELS_URL, timeout=5.0)
426
- if res.status_code == 200:
427
- results = []
428
- for mid in res.json().get("loaded") or []:
429
- deleted = await lc.delete(f"{BASE_URL}/models/unload/{mid}", timeout=15.0)
430
- results.append((mid, deleted.status_code))
431
- if results is not None:
432
- await send_message(client, chat_id, _unload_all_report(results))
433
- elif res.status_code == 200:
434
- await send_message(client, chat_id, f"✅ {model_id} 언로드 완료. RAM이 해제되었습니다.")
435
- else:
436
- await send_message(client, chat_id, f"언로드 실패 ({res.status_code})")
437
- except Exception as e:
438
- await send_message(client, chat_id, f"언로드 오류: {e}")
439
-
440
- # ── Knowledge Graph stats ─────────────────────────────────────────────────────
441
-
442
- async def show_graph_stats(client, chat_id):
443
- await send_chat_action(client, chat_id, "typing")
444
- try:
445
- async with _server_client() as lc:
446
- res = await lc.get(GRAPH_STATS_URL, timeout=5.0)
447
- data = res.json() if res.status_code == 200 else {}
448
- except Exception:
449
- data = {}
450
-
451
- nodes = data.get("nodes") or {}
452
- edges = data.get("edges") or {}
453
- total_nodes = sum(nodes.values())
454
- total_edges = sum(edges.values())
455
-
456
- node_lines = "\n".join(f" {t}: {c}" for t, c in sorted(nodes.items(), key=lambda x: -x[1])) or " 없음"
457
- edge_lines = "\n".join(f" {t}: {c}" for t, c in sorted(edges.items(), key=lambda x: -x[1])[:8]) or " 없음"
458
-
459
- text = (
460
- f"🕸 Knowledge Graph 통계\n\n"
461
- f"노드 총 {total_nodes}개:\n{node_lines}\n\n"
462
- f"엣지 총 {total_edges}개:\n{edge_lines}\n\n"
463
- f"그래프 보기: {get_graph_url()}"
464
- )
465
- markup = {
466
- "inline_keyboard": [[
467
- {"text": "🔗 그래프 열기", "url": get_graph_url()},
468
- {"text": "↩ 메뉴로", "callback_data": "cmd:menu"},
469
- ]]
470
- }
471
- await send_message(client, chat_id, text, reply_markup=markup)
472
-
473
- # ── Screenshot ────────────────────────────────────────────────────────────────
474
-
475
- async def take_screenshot(client, chat_id):
476
- await send_chat_action(client, chat_id, "upload_photo")
477
- # mkstemp, not mktemp: mktemp only predicts an unused name, leaving a
478
- # window in which anything can create that path first. mkstemp creates
479
- # the file atomically with 0600.
480
- _fd, _name = tempfile.mkstemp(suffix=".jpg")
481
- os.close(_fd)
482
- tmp = Path(_name)
483
- try:
484
- proc = await asyncio.create_subprocess_exec(
485
- "screencapture", "-x", str(tmp),
486
- stdout=asyncio.subprocess.DEVNULL,
487
- stderr=asyncio.subprocess.DEVNULL,
488
- )
489
- await asyncio.wait_for(proc.communicate(), timeout=10.0)
490
- if tmp.exists() and tmp.stat().st_size > 0:
491
- await send_photo(client, chat_id, tmp, caption="현재 화면입니다.")
492
- else:
493
- await send_message(client, chat_id, "스크린샷 파일이 생성되지 않았습니다. screencapture가 설치되어 있는지 확인하세요.")
494
- except asyncio.TimeoutError:
495
- await send_message(client, chat_id, "스크린샷 시간 초과")
496
- except FileNotFoundError:
497
- await send_message(client, chat_id, "screencapture 명령이 없습니다. macOS에서만 동작합니다.")
498
- except Exception as e:
499
- await send_message(client, chat_id, f"스크린샷 오류: {e}")
500
- finally:
501
- try:
502
- tmp.unlink(missing_ok=True)
503
- except Exception:
504
- quiet()
505
-
506
- # ── History ───────────────────────────────────────────────────────────────────
507
-
508
- async def show_history_summary(client, chat_id, n: int = 5):
509
- await send_chat_action(client, chat_id, "typing")
510
- try:
511
- async with _server_client() as lc:
512
- res = await lc.get(HISTORY_URL, timeout=10.0)
513
- items = res.json() if res.status_code == 200 else []
514
- except Exception:
515
- items = []
516
-
517
- if not items:
518
- await send_message(client, chat_id, "저장된 대화 기록이 없습니다.")
519
- return
520
-
521
- recent = [i for i in items if i.get("role") == "user"][-n:]
522
- lines = [f"📜 최근 사용자 메시지 {len(recent)}건\n"]
523
- for item in recent:
524
- ts = str(item.get("timestamp", ""))[:16]
525
- src = item.get("source", "web")
526
- content = str(item.get("content", ""))[:120].replace("\n", " ")
527
- lines.append(f"[{ts}] ({src}) {content}")
528
- await send_message(client, chat_id, "\n".join(lines))
529
-
530
- async def clear_server_history(client, chat_id, keep_last=0):
531
- try:
532
- async with _server_client() as lc:
533
- res = await lc.delete(HISTORY_URL, params={"keep_last": keep_last}, timeout=10.0)
534
- data = res.json() if res.headers.get("content-type", "").startswith("application/json") else {}
535
- if res.status_code == 200:
536
- await send_message(client, chat_id, f"대화 기록을 정리했습니다. 삭제 {data.get('removed', 0)}개, 유지 {data.get('kept', 0)}개.")
537
- else:
538
- await send_message(client, chat_id, f"대화 기록 정리 실패: {res.status_code}")
539
- except Exception as e:
540
- await send_message(client, chat_id, f"대화 기록 정리 오류: {e}")
541
-
542
- # ── Web UI link ───────────────────────────────────────────────────────────────
543
-
544
- async def send_web_link(client, chat_id):
545
- web_url = get_web_url()
546
- text = (
547
- "웹 UI 링크입니다.\n"
548
- f"{web_url}\n\n"
549
- "핸드폰이 Mac과 같은 Wi-Fi에 있어야 바로 열립니다. "
550
- "외부망에서 쓰려면 LATTICEAI_PUBLIC_URL에 터널 주소를 설정하세요."
551
- )
552
- payload = {
553
- "chat_id": chat_id,
554
- "text": text,
555
- "reply_markup": {
556
- "inline_keyboard": [[
557
- {"text": "Lattice AI Web 열기", "url": web_url},
558
- {"text": "Knowledge Graph", "url": get_graph_url()},
559
- ]]
560
- },
561
- }
562
- try:
563
- # The Telegram client, like every other helper here. Sending this on the
564
- # server client shipped the local bearer capability to api.telegram.org
565
- # and failed outright whenever that token was unset.
566
- await client.post(f"{API_URL}/sendMessage", json=payload)
567
- except Exception as e:
568
- logger.error("웹 링크 전송 실패: %s", safe_log_text(e))
569
-
570
- # ── MCP tools ─────────────────────────────────────────────────────────────────
571
-
572
- async def send_mcp_tools(client, chat_id):
573
- try:
574
- async with _server_client() as lc:
575
- res = await lc.get(MCP_TOOLS_URL, timeout=10.0)
576
- if res.status_code != 200:
577
- await send_message(client, chat_id, f"MCP 도구 목록을 가져오지 못했습니다: {res.status_code}")
578
- return
579
- data = res.json()
580
- names = [tool["name"] for tool in data.get("tools", [])]
581
- await send_message(client, chat_id, "사용 가능한 MCP 도구:\n" + ("\n".join(f"- {n}" for n in names) or "없음"))
582
- except Exception as e:
583
- await send_message(client, chat_id, f"MCP 도구 조회 실패: {e}")
584
-
585
- # ── Document upload → knowledge graph ────────────────────────────────────────
586
-
587
- async def process_document_file(client, chat_id, file_id: str, filename: str, caption: str = ""):
588
- await send_chat_action(client, chat_id, "upload_document")
589
- raw = await download_telegram_file(client, file_id)
590
- if not raw:
591
- await send_message(client, chat_id, "파일 다운로드 실패")
592
- return
593
-
594
- suffix = Path(filename).suffix.lower()
595
- allowed = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
596
- if suffix not in allowed:
597
- await send_message(client, chat_id,
598
- f"지원하지 않는 파일 형식입니다({suffix}). "
599
- f"지원 형식: {', '.join(sorted(allowed))}")
600
- return
601
-
602
- _fd, _name = tempfile.mkstemp(suffix=suffix) # see take_screenshot
603
- os.close(_fd)
604
- tmp = Path(_name)
605
- try:
606
- tmp.write_bytes(raw)
607
- async with _server_client() as lc:
608
- res = await lc.post(
609
- UPLOAD_DOC_URL,
610
- files={"file": (filename, raw)},
611
- timeout=60.0,
612
- )
613
- if res.status_code == 200:
614
- data = res.json()
615
- chars = data.get("chars") or len(raw)
616
- preview = str(data.get("preview") or "")[:300]
617
- kg = data.get("knowledge_graph") or {}
618
- node_id = kg.get("node_id", "")
619
- text = (
620
- f"✅ {filename} 수집 완료\n"
621
- f"크기: {len(raw) // 1024} KB | 문자: {chars}\n"
622
- f"노드: {node_id}\n"
623
- f"\n미리보기:\n{preview}"
624
- )
625
- await send_message(client, chat_id, text)
626
- else:
627
- err = res.json().get("detail") if res.headers.get("content-type", "").startswith("application/json") else res.text
628
- await send_message(client, chat_id, f"업로드 실패 ({res.status_code}): {err}")
629
- except Exception as e:
630
- await send_message(client, chat_id, f"문서 처리 오류: {e}")
631
- finally:
632
- tmp.unlink(missing_ok=True)
633
-
634
- # ── AI chat ───────────────────────────────────────────────────────────────────
635
-
636
- async def ask_ai(client, message, image_data=None, agent_mode=False,
637
- planning_model=None, executing_model=None, reviewing_model=None):
638
- try:
639
- if agent_mode and not image_data:
640
- url = AGENT_URL
641
- payload = {
642
- "message": message, "source": "telegram",
643
- "human_in_loop": True,
644
- "planning_model": planning_model,
645
- "executing_model": executing_model,
646
- "reviewing_model": reviewing_model,
647
- }
648
- else:
649
- url = CHAT_URL
650
- payload = {"message": message, "source": "telegram", "stream": False}
651
- if image_data:
652
- payload["image_data"] = image_data
653
- async with _server_client() as sc:
654
- res = await sc.post(url, json=payload, timeout=300.0)
655
- if res.status_code == 200:
656
- ct = res.headers.get("content-type", "")
657
- if "text/event-stream" in ct:
658
- text = ""
659
- for line in res.text.splitlines():
660
- if line.startswith("data:"):
661
- try:
662
- chunk = json.loads(line[5:].strip()).get("chunk", "")
663
- text += chunk
664
- except Exception:
665
- quiet()
666
- return {"response": text.strip() or "⚠️ 빈 응답"}
667
- return res.json()
668
- try:
669
- detail = res.json().get("detail", "")
670
- except Exception:
671
- detail = ""
672
- if res.status_code == 400 and "model" in detail.lower():
673
- return {"response": "⚠️ 로드된 모델이 없습니다. 먼저 /model 명령으로 모델을 선택해주세요."}
674
- return {"response": f"❌ 서버 에러 ({res.status_code}){': ' + detail if detail else ''}"}
675
- except Exception as e:
676
- return {"response": f"❌ 서버 연결 실패: {e}"}
677
-
678
- def resolve_workspace_file(relative_path):
679
- target = (AGENT_WORKSPACE / relative_path).resolve()
680
- if target != AGENT_WORKSPACE and AGENT_WORKSPACE not in target.parents:
681
- return None
682
- if not target.exists() or not target.is_file():
683
- return None
684
- if target.stat().st_size > MAX_TELEGRAM_FILE_BYTES:
685
- return None
686
- return target
687
-
688
- def collect_generated_files(agent_data):
689
- files, seen = [], set()
690
- for step in agent_data.get("steps", []):
691
- if step.get("action") not in {"write_file", "create_docx", "create_xlsx", "create_pptx", "create_pdf"}:
692
- continue
693
- path = (step.get("result") or {}).get("path") or (step.get("args") or {}).get("path")
694
- if not path or path in seen:
695
- continue
696
- target = resolve_workspace_file(path)
697
- if target:
698
- seen.add(path)
699
- files.append((path, target))
700
- return files
701
-
702
- def format_artifact_card(data, *, language: str = "ko") -> str:
703
- """Artifact card summary for a finished run (v10.4.0).
704
-
705
- Telegram used to send the produced files and nothing else, so a
706
- deterministically repaired scaffold arrived looking exactly like clean
707
- model output — SURFACE_PARITY recorded that as ◐. This renders the same
708
- ``artifacts[]`` flags the web cards show, and reports only what the server
709
- said: an absent flag is never promoted into a claim of validity.
710
- """
711
- if not isinstance(data, dict):
712
- return ""
713
- artifacts = data.get("artifacts")
714
- if not isinstance(artifacts, list) or not artifacts:
715
- return ""
716
- korean = not str(language or "ko").startswith("en")
717
- lines = ["📄 만든 파일" if korean else "📄 Files produced"]
718
- for item in artifacts[:8]:
719
- if not isinstance(item, dict):
720
- continue
721
- path = str(item.get("path") or "").strip()
722
- if not path:
723
- continue
724
- notes = []
725
- size = item.get("bytes")
726
- if isinstance(size, (int, float)) and size > 0:
727
- notes.append(f"{int(size):,} B")
728
- if item.get("repaired") is True:
729
- notes.append("자동 보정됨" if korean else "auto-repaired")
730
- if item.get("valid") is False:
731
- notes.append("검증 실패" if korean else "failed validation")
732
- suffix = f" ({' · '.join(notes)})" if notes else ""
733
- lines.append(f"• {path}{suffix}")
734
- extra = len(artifacts) - 8
735
- if extra > 0:
736
- lines.append(f"… +{extra}")
737
- return "\n".join(lines) if len(lines) > 1 else ""
738
-
739
-
740
- async def send_artifact_card(client, chat_id, data, *, language: str = "ko") -> None:
741
- text = format_artifact_card(data, language=language)
742
- if text:
743
- await send_message(client, chat_id, text)
744
-
745
-
746
- def format_grounding_badge(data, *, language: str = "ko") -> str:
747
- """Answer-grounding badge line for a `/chat` reply (v9.9.7).
748
-
749
- Telegram used to show only the answer text, so a reply the Brain could not
750
- ground looked identical to one built on real sources. This renders exactly
751
- the verdict the server issued — and reports an absent verdict as unknown
752
- rather than promoting it to "근거 있음".
753
- """
754
- if not isinstance(data, dict):
755
- return ""
756
- grounding = data.get("grounding")
757
- if not isinstance(grounding, dict):
758
- return ""
759
- status = str(grounding.get("status") or "")
760
- if not status:
761
- return ""
762
- korean = not str(language or "ko").startswith("en")
763
- cited = [
764
- str(item.get("title") or "").strip()
765
- for item in (grounding.get("cited") or [])
766
- if isinstance(item, dict) and str(item.get("title") or "").strip()
767
- ]
768
- if status == "supported":
769
- head = "✅ 근거 있음" if korean else "✅ grounded"
770
- if cited:
771
- head += " — " + ", ".join(cited[:3])
772
- return head
773
- if status in {"unsupported", "no_context"}:
774
- head = "⚠️ 근거 없음" if korean else "⚠️ not grounded"
775
- reason = str(grounding.get("reason") or "").strip()
776
- return f"{head} — {reason}" if reason else head
777
- return "❔ 근거 확인 불가" if korean else "❔ grounding unknown"
778
-
779
-
780
- async def send_grounding_badge(client, chat_id, data):
781
- text = format_grounding_badge(data)
782
- if text:
783
- await send_message(client, chat_id, text)
784
-
785
-
786
- def format_proposals(payload) -> list:
787
- """`GET /api/proposals` → ``[(item_id, label)]`` for the Review Center.
788
-
789
- Rows without an id are dropped: an un-actionable row is worse than none.
790
- """
791
- root = payload if isinstance(payload, dict) else {}
792
- rows = payload if isinstance(payload, list) else (root.get("items") or [])
793
- out = []
794
- for raw in rows if isinstance(rows, list) else []:
795
- if not isinstance(raw, dict):
796
- continue
797
- item_id = str(raw.get("id") or "").strip()
798
- if not item_id:
799
- continue
800
- raw_body = raw.get("payload")
801
- raw_prov = raw.get("provenance")
802
- body: Dict[str, Any] = raw_body if isinstance(raw_body, dict) else {}
803
- provenance: Dict[str, Any] = raw_prov if isinstance(raw_prov, dict) else {}
804
- path = str(body.get("path") or provenance.get("path") or "")
805
- title = str(raw.get("title") or path or item_id)
806
- change_class = str(body.get("change_class") or provenance.get("change_class") or "")
807
- label = title if not change_class else f"{title} ({change_class})"
808
- out.append((item_id, label))
809
- return out
810
-
811
-
812
- async def show_review_center(client, chat_id):
813
- """Review Center parity (v9.9.7): staged change proposals with inline
814
- approve/reject, using the same `/api/proposals` surface as the web app."""
815
- try:
816
- async with _server_client() as sc:
817
- res = await sc.get(PROPOSALS_URL, timeout=30.0)
818
- if res.status_code != 200:
819
- await send_message(client, chat_id, f"검토함을 불러오지 못했습니다 ({res.status_code})")
820
- return
821
- items = format_proposals(res.json())
822
- except Exception as exc:
823
- await send_message(client, chat_id, f"검토함 조회 실패: {exc}")
824
- return
825
- if not items:
826
- await send_message(client, chat_id, "🗂 검토할 변경 제안이 없습니다.")
827
- return
828
- lines = ["🗂 변경 제안 (승인해야 적용됩니다)"]
829
- keyboard = []
830
- for item_id, label in items[:8]:
831
- lines.append(f"- {label}")
832
- keyboard.append([
833
- {"text": f"✅ 승인 — {label}"[:64], "callback_data": f"proposal:approve:{item_id}"[:64]},
834
- {"text": "❌ 거절"[:64], "callback_data": f"proposal:reject:{item_id}"[:64]},
835
- ])
836
- if len(items) > 8:
837
- lines.append(f"… 외 {len(items) - 8}건")
838
- await send_message(
839
- client, chat_id, "\n".join(lines),
840
- reply_markup={"inline_keyboard": keyboard},
841
- )
842
-
843
-
844
- async def handle_proposal_callback(client, chat_id, data: str):
845
- """Apply or reject one staged proposal.
846
-
847
- A 409 means the target file changed since staging — nothing was written,
848
- and the user is told exactly that instead of a silent retry.
849
- """
850
- try:
851
- _, decision, item_id = data.split(":", 2)
852
- except ValueError:
853
- return
854
- if not item_id:
855
- return
856
- suffix = "approve" if decision == "approve" else "reject"
857
- url = f"{PROPOSALS_URL}/{item_id}/{suffix}"
858
- try:
859
- async with _server_client() as sc:
860
- res = await sc.post(url, json={}, timeout=120.0)
861
- except Exception as exc:
862
- await send_message(client, chat_id, f"❌ 처리 실패: {exc}")
863
- return
864
- if res.status_code == 409:
865
- await send_message(
866
- client, chat_id,
867
- "⚠️ 제안을 만든 뒤 파일이 바뀌어서 적용하지 않았습니다. 아무것도 쓰지 않았습니다.",
868
- )
869
- return
870
- if res.status_code != 200:
871
- await send_message(client, chat_id, f"❌ 서버 에러 ({res.status_code})")
872
- return
873
- if suffix == "approve":
874
- try:
875
- applied = res.json().get("path") or item_id
876
- except Exception:
877
- applied = item_id
878
- await send_message(client, chat_id, f"✅ 적용했습니다: {applied}")
879
- else:
880
- await send_message(client, chat_id, "🚫 제안을 거절했습니다.")
881
-
882
-
883
- def format_run_explanation(agent_data, *, language: str = "ko") -> str:
884
- """Plain-language outcome line for a finished agent run (v9.9.6).
885
-
886
- Telegram used to show only the final message, so a NEEDS_REVIEW run read
887
- exactly like a success. The server already computes the honest sentence
888
- (`explanation`); this renders it. Returns "" when there is nothing to add
889
- — a clean, verified run gets no extra noise.
890
- """
891
- if not isinstance(agent_data, dict):
892
- return ""
893
- explanation = agent_data.get("explanation")
894
- if not isinstance(explanation, dict):
895
- return ""
896
- lang = "ko" if str(language or "ko").startswith("ko") else "en"
897
-
898
- def pick(entry):
899
- return str(entry.get(lang) or "") if isinstance(entry, dict) else ""
900
-
901
- details = [pick(item) for item in explanation.get("details") or []]
902
- details = [line for line in details if line]
903
- if explanation.get("ok") and not details:
904
- return ""
905
- headline = pick(explanation.get("headline"))
906
- marker = "✅" if explanation.get("ok") else "⚠️"
907
- lines = [f"{marker} {headline}" if headline else marker]
908
- lines.extend(f"· {line}" for line in details[:4])
909
- return "\n".join(lines)
910
-
911
-
912
- async def send_run_explanation(client, chat_id, agent_data):
913
- text = format_run_explanation(agent_data)
914
- if text:
915
- await send_message(client, chat_id, text)
916
-
917
-
918
- def collect_preview_urls(agent_data):
919
- urls, seen = [], set()
920
- for step in agent_data.get("steps", []):
921
- if step.get("action") != "preview_url":
922
- continue
923
- result = step.get("result") or {}
924
- local_url = result.get("local_url")
925
- if not local_url or local_url in seen:
926
- continue
927
- phone_url = local_url.replace("http://127.0.0.1:4825", f"http://{get_lan_ip()}:{SERVER_PORT}")
928
- seen.add(local_url)
929
- urls.append((result.get("path") or "preview", phone_url))
930
- return urls
931
-
932
- async def send_preview_links(client, chat_id, preview_urls):
933
- if not preview_urls:
934
- return
935
- lines = ["미리보기 링크 (Mac과 같은 Wi-Fi 필요):"]
936
- keyboard = []
937
- for label, url in preview_urls:
938
- lines.append(f"- {label}: {url}")
939
- keyboard.append([{"text": f"{label} 열기"[:64], "url": url}])
940
- await send_message(client, chat_id, "\n".join(lines), reply_markup={"inline_keyboard": keyboard[:8]})
941
-
942
- async def send_generated_files(client, chat_id, generated_files):
943
- if not generated_files:
944
- return
945
- if len(generated_files) == 1:
946
- path, fpath = generated_files[0]
947
- await send_document(client, chat_id, fpath, caption=f"생성 파일: {path}")
948
- return
949
- with tempfile.NamedTemporaryFile(prefix="ltcai-", suffix=".zip", delete=False) as tmp:
950
- zip_path = Path(tmp.name)
951
- try:
952
- with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
953
- for rel, fpath in generated_files:
954
- zf.write(fpath, arcname=rel)
955
- if zip_path.stat().st_size <= MAX_TELEGRAM_FILE_BYTES:
956
- await send_document(client, chat_id, zip_path,
957
- caption=f"생성 파일 {len(generated_files)}개", filename="ltcai-files.zip")
958
- else:
959
- await send_message(client, chat_id, "생성 파일이 너무 커서 전송할 수 없습니다.")
960
- finally:
961
- zip_path.unlink(missing_ok=True)
962
-
963
- # ── Plan approval (Human-in-the-loop) ────────────────────────────────────────
964
-
965
- def _approval_pause_id(data: dict) -> str:
966
- """Stable key for a paused plan: run_id preferred, legacy context_id fallback."""
967
- return str(data.get("run_id") or data.get("context_id") or "")
968
-
969
-
970
- def _is_approval_pause(data: dict) -> bool:
971
- status = str(data.get("status") or "")
972
- return status in {"waiting_approval", "awaiting_approval"}
973
-
974
-
975
- async def send_plan_for_approval(client, chat_id, data: dict) -> None:
976
- """Show the agent plan to the user and present Done/Cancel buttons.
977
-
978
- Handles both the modern ``awaiting_approval`` (run_id + token) and the
979
- legacy ``waiting_approval`` / ``context_id`` wire contracts (v9.9.5
980
- SURFACE_PARITY — Telegram approval flow).
981
- """
982
- pause_id = _approval_pause_id(data)
983
- if not pause_id:
984
- await send_message(client, chat_id, "❌ 승인할 계획을 식별할 수 없습니다.")
985
- return
986
- plan = data.get("plan", {}) or {}
987
- goal = plan.get("goal") or (data.get("approval") or {}).get("plan_summary") or ""
988
- steps = plan.get("steps", []) or data.get("non_auto_steps") or []
989
- p_model = data.get("planning_model", "current")
990
- e_model = data.get("executing_model", "current")
991
- r_model = data.get("reviewing_model", "current")
992
- approval = data.get("approval") or {}
993
- expires_at = approval.get("expires_at")
994
-
995
- lines = ["📋 *플래닝 완료* — 실행 전 확인해주세요\n"]
996
- if goal:
997
- lines.append(f"*목표:* {goal}\n")
998
- for i, step in enumerate(steps, 1):
999
- if isinstance(step, dict):
1000
- desc = step.get("description") or step.get("action") or str(step)
1001
- else:
1002
- desc = str(step)
1003
- lines.append(f"{i}. {desc}")
1004
- lines.append(f"\n🧠 플래닝: `{p_model}`")
1005
- lines.append(f"⚙️ 실행: `{e_model}`")
1006
- lines.append(f"🔍 검토: `{r_model}`")
1007
- if expires_at:
1008
- lines.append(f"⏳ 승인 만료: `{expires_at}`")
1009
-
1010
- _bot_pending_plans[pause_id] = {
1011
- "chat_id": chat_id,
1012
- "run_id": data.get("run_id") or pause_id,
1013
- "context_id": data.get("context_id"),
1014
- "approval_token": approval.get("token"),
1015
- "legacy": data.get("status") == "waiting_approval" or bool(data.get("context_id")),
1016
- "executing_model": data.get("executing_model"),
1017
- "reviewing_model": data.get("reviewing_model"),
1018
- }
1019
-
1020
- # callback_data max 64 bytes — pause ids are token_urlsafe(16) (~22 chars).
1021
- keyboard = {"inline_keyboard": [[
1022
- {"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{pause_id}"},
1023
- {"text": "❌ 취소", "callback_data": f"plan:cancel:{pause_id}"},
1024
- ]]}
1025
- await send_message(client, chat_id, "\n".join(lines), reply_markup=keyboard)
1026
-
1027
-
1028
- async def handle_plan_callback(client, chat_id, data: str) -> None:
1029
- """Handle Done/Cancel callback from plan approval buttons."""
1030
- parts = data.split(":", 2)
1031
- if len(parts) != 3:
1032
- return
1033
- _, action, pause_id = parts
1034
- pending = _bot_pending_plans.get(pause_id)
1035
-
1036
- # A callback is bound to the chat that received the plan. Even two allowed
1037
- # chats cannot approve or cancel each other's plan by replaying callback
1038
- # data.
1039
- if pending and int(pending.get("chat_id") or 0) != int(chat_id):
1040
- await send_message(client, chat_id, "❌ 다른 채팅의 작업은 처리할 수 없습니다.")
1041
- return
1042
- pending = _bot_pending_plans.pop(pause_id, None)
1043
-
1044
- if action == "cancel":
1045
- if pending:
1046
- try:
1047
- resume_body = _resume_payload(pending, approved=False)
1048
- async with _server_client() as sc:
1049
- await sc.post(AGENT_RESUME_URL, json=resume_body, timeout=30.0)
1050
- except Exception as exc: # noqa: BLE001 — cancel is best-effort
1051
- logger.warning("telegram cancel resume failed: %s", safe_log_text(exc))
1052
- await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
1053
- return
1054
- if not pending:
1055
- await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
1056
- return
1057
-
1058
- await send_message(client, chat_id, "⚙️ 실행 중입니다. 잠시 기다려주세요...")
1059
- await send_chat_action(client, chat_id, "typing")
1060
-
1061
- try:
1062
- async with _server_client() as sc:
1063
- res = await sc.post(
1064
- AGENT_RESUME_URL,
1065
- json=_resume_payload(pending, approved=True),
1066
- timeout=300.0,
1067
- )
1068
- data_r = res.json() if res.status_code == 200 else {}
1069
- ans = data_r.get("response", f"❌ 서버 에러 ({res.status_code})")
1070
- await send_message(client, chat_id, str(ans))
1071
- if isinstance(data_r, dict):
1072
- await send_run_explanation(client, chat_id, data_r)
1073
- await send_artifact_card(client, chat_id, data_r)
1074
- await send_generated_files(client, chat_id, collect_generated_files(data_r))
1075
- await send_preview_links(client, chat_id, collect_preview_urls(data_r))
1076
- except Exception as e:
1077
- await send_message(client, chat_id, f"❌ 실행 중 오류: {e}")
1078
-
1079
-
1080
- def _resume_payload(pending: dict, *, approved: bool) -> dict:
1081
- """Build /agent/resume body: token path preferred, else legacy context_id."""
1082
- body: dict = {
1083
- "approved": approved,
1084
- "executing_model": pending.get("executing_model"),
1085
- "reviewing_model": pending.get("reviewing_model"),
1086
- }
1087
- token = pending.get("approval_token")
1088
- run_id = pending.get("run_id")
1089
- # Unified durable store (9.9.5): token-gated resume works for both the
1090
- # modern awaiting_approval and the legacy human_in_loop pause.
1091
- if token and run_id:
1092
- body["run_id"] = run_id
1093
- body["approval_token"] = token
1094
- return body
1095
- body["context_id"] = pending.get("context_id") or run_id
1096
- return body
1097
-
1098
-
1099
- # ── AI request task ───────────────────────────────────────────────────────────
1100
-
1101
- async def process_ai_request(client, chat_id, user_text, image_data=None):
1102
- try:
1103
- await send_chat_action(client, chat_id, "upload_photo" if image_data else "typing")
1104
- logger.info("ask_ai 호출 시작: chat_id=%s text=%r", chat_id, safe_log_text(user_text[:30]))
1105
- data = await ask_ai(client, user_text, image_data, agent_mode=not image_data)
1106
- logger.info("ask_ai 완료: chat_id=%s result_keys=%s", chat_id, list(data.keys()) if isinstance(data, dict) else type(data))
1107
-
1108
- # Approval pause (legacy waiting_approval or modern awaiting_approval)
1109
- if isinstance(data, dict) and _is_approval_pause(data):
1110
- await send_plan_for_approval(client, chat_id, data)
1111
- return
1112
-
1113
- ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
1114
- if not ans or not str(ans).strip():
1115
- ans = "⚠️ AI가 답변을 생성하지 못했습니다."
1116
- await send_message(client, chat_id, str(ans))
1117
- if isinstance(data, dict):
1118
- # Recall parity (v9.9.7): the same grounding verdict the web app
1119
- # badges, never promoted when the server issued none.
1120
- await send_grounding_badge(client, chat_id, data)
1121
- if not image_data and isinstance(data, dict):
1122
- await send_run_explanation(client, chat_id, data)
1123
- await send_artifact_card(client, chat_id, data)
1124
- await send_generated_files(client, chat_id, collect_generated_files(data))
1125
- await send_preview_links(client, chat_id, collect_preview_urls(data))
1126
- except Exception as e:
1127
- logger.error("process_ai_request 실패 (chat_id=%s): %s", chat_id, safe_log_text(e), exc_info=True)
1128
- try:
1129
- await send_message(client, chat_id, "⚠️ 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.")
1130
- except Exception:
1131
- quiet()
1132
-
1133
- # ── Command dispatch ──────────────────────────────────────────────────────────
1134
-
1135
- HELP_TEXT = """\
1136
- 🧠 Lattice AI 원격 제어 명령어
1137
-
1138
- /menu — 메인 메뉴 (인라인 키보드)
1139
- /status — 서버 상태 및 메모리
1140
- /model — 현재 모델 + 언로드 버튼
1141
- /unload — 모든 모델 언로드 (RAM 해제)
1142
- /graph — Knowledge Graph 통계
1143
- /ss 또는 /screenshot — 현재 화면 캡처
1144
- /history [n] — 최근 대화 n건 (기본 5)
1145
- /clear [n] — 기록 정리 (마지막 n건 유지)
1146
- /web — 웹 UI 링크
1147
- /mcp — MCP 도구 목록
1148
- /review — 변경 제안 검토함 (승인/거절)
1149
- /help — 이 도움말
1150
-
1151
- /agent <작업> — 멀티 LLM 에이전트 (계획 확인 후 실행)
1152
- /agent <작업> --exec <모델> --review <모델> — 실행/검토 LLM 지정
1153
-
1154
- 일반 텍스트 → AI에게 질문
1155
- 사진 전송 → AI 이미지 분석
1156
- 문서 전송(PDF, DOCX, XLSX, PPTX, TXT, CSV) → Knowledge Graph 수집
1157
- """
1158
-
1159
- async def handle_command(client, chat_id, command: str, args: str):
1160
- cmd = command.lower().lstrip("/").split("@")[0]
1161
-
1162
- if cmd == "start":
1163
- await send_message(client, chat_id, "🧠 Lattice AI 원격 제어 준비 완료!")
1164
- await show_menu(client, chat_id)
1165
- elif cmd == "menu":
1166
- await show_menu(client, chat_id)
1167
- elif cmd == "status":
1168
- await show_status(client, chat_id)
1169
- elif cmd == "model":
1170
- await show_model_info(client, chat_id)
1171
- elif cmd == "unload":
1172
- await do_unload_model(client, chat_id)
1173
- elif cmd == "graph":
1174
- await show_graph_stats(client, chat_id)
1175
- elif cmd in {"ss", "screenshot"}:
1176
- await take_screenshot(client, chat_id)
1177
- elif cmd == "history":
1178
- n = int(args.strip()) if args.strip().isdigit() else 5
1179
- await show_history_summary(client, chat_id, n)
1180
- elif cmd in {"clear", "clear_history", "forget"}:
1181
- keep = int(args.strip()) if args.strip().isdigit() else 0
1182
- await clear_server_history(client, chat_id, keep)
1183
- elif cmd == "web":
1184
- await send_web_link(client, chat_id)
1185
- elif cmd == "mcp":
1186
- await send_mcp_tools(client, chat_id)
1187
- elif cmd in {"review", "proposals"}:
1188
- await show_review_center(client, chat_id)
1189
- elif cmd in {"help", "h"}:
1190
- await send_message(client, chat_id, HELP_TEXT)
1191
- elif cmd == "agent":
1192
- if not args:
1193
- await send_message(client, chat_id, "사용법: /agent <작업 내용>\n예: /agent 쇼핑몰 메인 페이지 HTML 만들어줘\n\n특정 AI 지정:\n/agent <작업> --exec openai/gpt-4o --review together:Qwen/Qwen3-VL-32B-Instruct")
1194
- return
1195
- # Parse optional --exec / --review flags
1196
- exec_model = reviewing_model = None
1197
- task_text = args
1198
- import re as _re
1199
- em = _re.search(r'--exec\s+(\S+)', args)
1200
- rm = _re.search(r'--review\s+(\S+)', args)
1201
- if em:
1202
- exec_model = em.group(1)
1203
- task_text = task_text.replace(em.group(0), "").strip()
1204
- if rm:
1205
- reviewing_model = rm.group(1)
1206
- task_text = task_text.replace(rm.group(0), "").strip()
1207
- await send_chat_action(client, chat_id, "typing")
1208
- data = await ask_ai(client, task_text, agent_mode=True,
1209
- executing_model=exec_model, reviewing_model=reviewing_model)
1210
- if isinstance(data, dict) and _is_approval_pause(data):
1211
- await send_plan_for_approval(client, chat_id, data)
1212
- else:
1213
- ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
1214
- await send_message(client, chat_id, ans)
1215
- else:
1216
- await send_message(client, chat_id, f"알 수 없는 명령어: /{cmd}\n/help 로 명령어 목록을 확인하세요.")
1217
-
1218
- # ── Callback query handler ────────────────────────────────────────────────────
1219
-
1220
- async def handle_callback_query(client, callback_query):
1221
- cq_id = callback_query.get("id", "")
1222
- chat_id = (
1223
- (callback_query.get("message") or {}).get("chat") or {}
1224
- ).get("id")
1225
- data = callback_query.get("data", "")
1226
-
1227
- if not is_chat_allowed(chat_id):
1228
- logger.warning("허용되지 않은 텔레그램 callback 차단: %s", safe_log_text(chat_id))
1229
- await answer_callback(client, cq_id, "허용되지 않은 채팅입니다.")
1230
- return
1231
-
1232
- await answer_callback(client, cq_id)
1233
-
1234
- if data == "cmd:status":
1235
- await show_status(client, chat_id)
1236
- elif data == "cmd:model":
1237
- await show_model_info(client, chat_id)
1238
- elif data == "cmd:graph":
1239
- await show_graph_stats(client, chat_id)
1240
- elif data == "cmd:screenshot":
1241
- await take_screenshot(client, chat_id)
1242
- elif data == "cmd:history":
1243
- await show_history_summary(client, chat_id, 5)
1244
- elif data == "cmd:clear":
1245
- await clear_server_history(client, chat_id, 0)
1246
- elif data == "cmd:web":
1247
- await send_web_link(client, chat_id)
1248
- elif data == "cmd:mcp":
1249
- await send_mcp_tools(client, chat_id)
1250
- elif data == "cmd:review":
1251
- await show_review_center(client, chat_id)
1252
- elif data == "cmd:menu":
1253
- await show_menu(client, chat_id)
1254
- elif data.startswith("model:unload:"):
1255
- model_id = data[len("model:unload:"):]
1256
- await do_unload_model(client, chat_id, model_id)
1257
- elif data.startswith("plan:"):
1258
- task = asyncio.create_task(handle_plan_callback(client, chat_id, data))
1259
- task.add_done_callback(_log_task_exception)
1260
- elif data.startswith("proposal:"):
1261
- task = asyncio.create_task(handle_proposal_callback(client, chat_id, data))
1262
- task.add_done_callback(_log_task_exception)
1263
-
1264
- # ── Main loop ─────────────────────────────────────────────────────────────────
1265
-
1266
- async def run_bot():
1267
- if not TOKEN:
1268
- logger.warning("LATTICEAI_TELEGRAM_BOT_TOKEN이 설정되지 않아 텔레그램 봇을 시작하지 않습니다.")
1269
- return
1270
- if not allowed_chat_ids():
1271
- logger.error(
1272
- "LATTICEAI_TELEGRAM_ALLOWED_CHAT_IDS가 없어 텔레그램 봇을 시작하지 않습니다."
1273
- )
1274
- return
1275
- if not _get_server_session():
1276
- logger.error(
1277
- "LATTICEAI_SERVER_SESSION_TOKEN이 없어 텔레그램 봇을 시작하지 않습니다."
1278
- )
1279
- return
1280
-
1281
- logger.info("🚀 비동기 텔레그램 봇 모드 시작!")
1282
- last_update_id = None
1283
- retry_delay = 1
1284
-
1285
- async with httpx.AsyncClient() as client:
1286
- while True:
1287
- try:
1288
- updates = await get_updates(client, last_update_id)
1289
- retry_delay = 1
1290
- except Exception as e:
1291
- logger.error("get_updates 실패: %s", safe_log_text(e))
1292
- await asyncio.sleep(min(retry_delay, 30))
1293
- retry_delay = min(retry_delay * 2, 30)
1294
- continue
1295
-
1296
- if not (updates and updates.get("ok")):
1297
- await asyncio.sleep(0.5)
1298
- continue
1299
-
1300
- for update in updates.get("result", []):
1301
- try:
1302
- last_update_id = update.get("update_id") + 1
1303
-
1304
- # ── Callback query (inline button press) ──────────────────
1305
- if "callback_query" in update:
1306
- task = asyncio.create_task(handle_callback_query(client, update["callback_query"]))
1307
- task.add_done_callback(_log_task_exception)
1308
- continue
1309
-
1310
- if "message" not in update:
1311
- continue
1312
-
1313
- msg = update["message"]
1314
- chat_id = msg["chat"]["id"]
1315
- if not is_chat_allowed(chat_id):
1316
- logger.warning(
1317
- "허용되지 않은 텔레그램 메시지 차단: %s",
1318
- safe_log_text(chat_id),
1319
- )
1320
- continue
1321
- register_chat_id(chat_id)
1322
- text = msg.get("text", "")
1323
- caption = msg.get("caption", "")
1324
-
1325
- # ── Photo → vision AI ─────────────────────────────────────
1326
- if "photo" in msg:
1327
- file_id = msg["photo"][-1]["file_id"]
1328
- await send_message(client, chat_id, "📸 사진을 받았습니다. 분석을 시작합니다...")
1329
- image_data = await download_as_base64(client, file_id)
1330
- prompt = caption or text or "이 이미지를 분석해줘."
1331
- task = asyncio.create_task(process_ai_request(client, chat_id, prompt, image_data))
1332
- task.add_done_callback(_log_task_exception)
1333
- continue
1334
-
1335
- # ── Document ──────────────────────────────────────────────
1336
- if "document" in msg:
1337
- doc = msg["document"]
1338
- mime = doc.get("mime_type", "")
1339
- filename = doc.get("file_name", "file")
1340
- if mime.startswith("image/"):
1341
- image_data = await download_as_base64(client, doc["file_id"])
1342
- prompt = caption or text or "이 이미지를 분석해줘."
1343
- task = asyncio.create_task(process_ai_request(client, chat_id, prompt, image_data))
1344
- else:
1345
- await send_message(client, chat_id, f"📄 {filename} 을 Knowledge Graph에 수집합니다...")
1346
- task = asyncio.create_task(
1347
- process_document_file(client, chat_id, doc["file_id"], filename, caption)
1348
- )
1349
- task.add_done_callback(_log_task_exception)
1350
- continue
1351
-
1352
- # ── Voice / audio ─────────────────────────────────────────
1353
- if "voice" in msg or "audio" in msg:
1354
- await send_message(
1355
- client, chat_id,
1356
- "🎤 음성 메시지를 받았습니다. 현재 음성 인식(Whisper)이 설정되어 있지 않습니다.\n"
1357
- "텍스트로 질문을 보내주세요."
1358
- )
1359
- continue
1360
-
1361
- if not text:
1362
- continue
1363
-
1364
- # ── Commands ──────────────────────────────────────────────
1365
- if text.startswith("/"):
1366
- parts = text.split(None, 1)
1367
- command = parts[0]
1368
- args = parts[1] if len(parts) > 1 else ""
1369
- task = asyncio.create_task(handle_command(client, chat_id, command, args))
1370
- task.add_done_callback(_log_task_exception)
1371
- continue
1372
-
1373
- # ── Plain text → AI ───────────────────────────────────────
1374
- task = asyncio.create_task(process_ai_request(client, chat_id, text))
1375
- task.add_done_callback(_log_task_exception)
1376
-
1377
- except Exception as e:
1378
- logger.error("업데이트 처리 중 예외: %s", safe_log_text(e))
1379
-
1380
- await asyncio.sleep(0.5)
1381
-
1382
- def _log_task_exception(task):
1383
- if not task.cancelled() and task.exception():
1384
- logger.error("백그라운드 태스크 예외: %s", safe_log_text(task.exception()))
1385
-
1386
- if __name__ == "__main__":
1387
- try:
1388
- asyncio.run(run_bot())
1389
- except KeyboardInterrupt:
1390
- quiet()