ltcai 11.2.0 → 11.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/README.md +46 -53
  2. package/docs/CHANGELOG.md +61 -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 +176 -0
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  16. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  17. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  18. package/lattice_brain/graph/_kg_common/text.py +479 -0
  19. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  20. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  21. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  22. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  23. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  24. package/lattice_brain/graph/projection/__init__.py +42 -0
  25. package/lattice_brain/graph/projection/curation.py +500 -0
  26. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
  27. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  28. package/lattice_brain/graph/retrieval/context.py +197 -0
  29. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  30. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  31. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  32. package/lattice_brain/graph/retrieval/signals.py +95 -0
  33. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  34. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  35. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  36. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  37. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  38. package/lattice_brain/ingestion/__init__.py +130 -0
  39. package/lattice_brain/ingestion/_contract.py +90 -0
  40. package/lattice_brain/ingestion/constants.py +127 -0
  41. package/lattice_brain/ingestion/folder_scan.py +57 -0
  42. package/lattice_brain/ingestion/folders.py +258 -0
  43. package/lattice_brain/ingestion/hashing.py +26 -0
  44. package/lattice_brain/ingestion/jobs_api.py +107 -0
  45. package/lattice_brain/ingestion/models.py +80 -0
  46. package/lattice_brain/ingestion/pipeline.py +486 -0
  47. package/lattice_brain/ingestion/quality.py +209 -0
  48. package/lattice_brain/ingestion/routing.py +295 -0
  49. package/lattice_brain/multimodal/__init__.py +164 -0
  50. package/lattice_brain/multimodal/audio.py +77 -0
  51. package/lattice_brain/multimodal/common.py +118 -0
  52. package/lattice_brain/multimodal/images.py +498 -0
  53. package/lattice_brain/multimodal/ports.py +169 -0
  54. package/lattice_brain/multimodal/video.py +410 -0
  55. package/lattice_brain/portability/__init__.py +90 -0
  56. package/lattice_brain/portability/_contract.py +42 -0
  57. package/lattice_brain/portability/backups.py +338 -0
  58. package/lattice_brain/portability/bundles.py +136 -0
  59. package/lattice_brain/portability/constants.py +93 -0
  60. package/lattice_brain/portability/fsops.py +138 -0
  61. package/lattice_brain/portability/service.py +41 -0
  62. package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
  63. package/lattice_brain/runtime/__init__.py +1 -1
  64. package/lattice_brain/runtime/multi_agent.py +1 -1
  65. package/latticeai/__init__.py +1 -1
  66. package/latticeai/api/chronicle.py +63 -0
  67. package/latticeai/core/agent/__init__.py +93 -0
  68. package/latticeai/core/agent/_contract.py +79 -0
  69. package/latticeai/core/agent/context.py +57 -0
  70. package/latticeai/core/agent/deps.py +125 -0
  71. package/latticeai/core/agent/execution.py +622 -0
  72. package/latticeai/core/agent/planning.py +145 -0
  73. package/latticeai/core/agent/recovery.py +157 -0
  74. package/latticeai/core/agent/runtime.py +210 -0
  75. package/latticeai/core/agent/verification.py +231 -0
  76. package/latticeai/core/embedding_providers/__init__.py +151 -0
  77. package/latticeai/core/embedding_providers/base.py +199 -0
  78. package/latticeai/core/embedding_providers/captions.py +162 -0
  79. package/latticeai/core/embedding_providers/profiles.py +126 -0
  80. package/latticeai/core/embedding_providers/text.py +350 -0
  81. package/latticeai/core/embedding_providers/vision.py +352 -0
  82. package/latticeai/core/file_generation/__init__.py +115 -0
  83. package/latticeai/core/file_generation/bundles.py +76 -0
  84. package/latticeai/core/file_generation/extraction.py +154 -0
  85. package/latticeai/core/file_generation/inference.py +235 -0
  86. package/latticeai/core/file_generation/orchestration.py +152 -0
  87. package/latticeai/core/file_generation/prompting.py +117 -0
  88. package/latticeai/core/file_generation/repair.py +114 -0
  89. package/latticeai/core/file_generation/sanitize.py +61 -0
  90. package/latticeai/core/file_generation/validation.py +201 -0
  91. package/latticeai/core/legacy_compatibility.py +1 -1
  92. package/latticeai/core/marketplace.py +1 -1
  93. package/latticeai/core/messages.py +9 -0
  94. package/latticeai/core/workspace_os_constants.py +1 -1
  95. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  96. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  97. package/latticeai/integrations/telegram_bot/config.py +86 -0
  98. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  99. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  100. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  101. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  102. package/latticeai/models/router/__init__.py +88 -0
  103. package/latticeai/models/router/_contract.py +66 -0
  104. package/latticeai/models/router/branding.py +56 -0
  105. package/latticeai/models/router/catalog.py +69 -0
  106. package/latticeai/models/router/documents.py +199 -0
  107. package/latticeai/models/router/errors.py +37 -0
  108. package/latticeai/models/router/generation.py +258 -0
  109. package/latticeai/models/router/loading.py +291 -0
  110. package/latticeai/models/router/local_models.py +85 -0
  111. package/latticeai/models/router/registry.py +147 -0
  112. package/latticeai/runtime/build_phases/__init__.py +82 -0
  113. package/latticeai/runtime/build_phases/features.py +407 -0
  114. package/latticeai/runtime/build_phases/foundation.py +555 -0
  115. package/latticeai/runtime/build_phases/web.py +492 -0
  116. package/latticeai/runtime/runtime_context.py +1 -0
  117. package/latticeai/services/architecture_readiness.py +48 -19
  118. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  119. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  120. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  121. package/latticeai/services/brain_intelligence/constants.py +47 -0
  122. package/latticeai/services/brain_intelligence/digest.py +258 -0
  123. package/latticeai/services/brain_intelligence/health.py +331 -0
  124. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  125. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  126. package/latticeai/services/brain_intelligence/service.py +48 -0
  127. package/latticeai/services/chronicle.py +557 -0
  128. package/latticeai/services/memory_service/__init__.py +52 -0
  129. package/latticeai/services/memory_service/_contract.py +100 -0
  130. package/latticeai/services/memory_service/brief.py +431 -0
  131. package/latticeai/services/memory_service/constants.py +57 -0
  132. package/latticeai/services/memory_service/maintenance.py +138 -0
  133. package/latticeai/services/memory_service/manager.py +186 -0
  134. package/latticeai/services/memory_service/proof.py +136 -0
  135. package/latticeai/services/memory_service/recall.py +225 -0
  136. package/latticeai/services/memory_service/service.py +48 -0
  137. package/latticeai/services/memory_service/stores.py +110 -0
  138. package/latticeai/services/model_runtime/__init__.py +322 -0
  139. package/latticeai/services/model_runtime/cloud.py +87 -0
  140. package/latticeai/services/model_runtime/download.py +282 -0
  141. package/latticeai/services/model_runtime/engines.py +341 -0
  142. package/latticeai/services/model_runtime/loading.py +178 -0
  143. package/latticeai/services/model_runtime/service.py +129 -0
  144. package/latticeai/services/model_runtime/state.py +131 -0
  145. package/latticeai/services/model_runtime/status.py +255 -0
  146. package/latticeai/services/product_readiness.py +15 -7
  147. package/latticeai/setup/wizard/__init__.py +126 -0
  148. package/latticeai/setup/wizard/catalog.py +172 -0
  149. package/latticeai/setup/wizard/detect.py +323 -0
  150. package/latticeai/setup/wizard/install.py +348 -0
  151. package/latticeai/setup/wizard/paths.py +168 -0
  152. package/latticeai/setup/wizard/plans.py +74 -0
  153. package/latticeai/setup/wizard/recommend.py +320 -0
  154. package/package.json +6 -2
  155. package/scripts/bump_version.py +14 -0
  156. package/scripts/capture_release_evidence.mjs +33 -21
  157. package/scripts/check_current_release_docs.mjs +1 -1
  158. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  159. package/scripts/check_max_file_lines.mjs +102 -0
  160. package/scripts/check_release_evidence_bound.mjs +30 -15
  161. package/scripts/check_screenshot_pixel_delta.py +34 -4
  162. package/scripts/check_server_i18n.mjs +1 -0
  163. package/scripts/generate_rust_parity_fixtures.py +562 -0
  164. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  165. package/scripts/release_screen_claims.json +31 -2
  166. package/src-tauri/Cargo.lock +361 -3
  167. package/src-tauri/Cargo.toml +6 -1
  168. package/src-tauri/src/backend.rs +349 -0
  169. package/src-tauri/src/folder.rs +33 -0
  170. package/src-tauri/src/main.rs +97 -399
  171. package/src-tauri/tauri.conf.json +1 -1
  172. package/static/app/asset-manifest.json +41 -37
  173. package/static/app/assets/Act-yYpYnn0v.js +1 -0
  174. package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
  175. package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
  176. package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
  177. package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
  178. package/static/app/assets/Capture-CFIRsFNE.js +1 -0
  179. package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
  180. package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
  181. package/static/app/assets/Library-DwO3yZST.js +1 -0
  182. package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
  183. package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
  184. package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
  185. package/static/app/assets/System-DW8F-2xL.js +1 -0
  186. package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
  187. package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
  188. package/static/app/assets/brain-Ci1CkWjM.js +1 -0
  189. package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
  190. package/static/app/assets/circle-check-DfInj-qD.js +1 -0
  191. package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
  192. package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
  193. package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
  194. package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
  195. package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
  196. package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
  197. package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
  198. package/static/app/assets/index-_u5iUHDr.js +10 -0
  199. package/static/app/assets/input-B0lPdRQZ.js +1 -0
  200. package/static/app/assets/link-2-CoFbooHS.js +1 -0
  201. package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
  202. package/static/app/assets/primitives-DEbN-d6p.js +1 -0
  203. package/static/app/assets/search-BybIWPNd.js +1 -0
  204. package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
  205. package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
  206. package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
  207. package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
  208. package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
  209. package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
  210. package/static/app/assets/utils-BlZr7Pd4.js +4 -0
  211. package/static/app/assets/workspace-jJY4RuAV.js +1 -0
  212. package/static/app/index.html +4 -4
  213. package/static/sw.js +1 -1
  214. package/lattice_brain/graph/_kg_common.py +0 -1331
  215. package/lattice_brain/graph/discovery_index.py +0 -1141
  216. package/lattice_brain/graph/retrieval.py +0 -1120
  217. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  218. package/lattice_brain/ingestion.py +0 -1525
  219. package/lattice_brain/multimodal.py +0 -1258
  220. package/latticeai/core/agent.py +0 -1465
  221. package/latticeai/core/embedding_providers.py +0 -1196
  222. package/latticeai/core/file_generation.py +0 -1047
  223. package/latticeai/integrations/telegram_bot.py +0 -1390
  224. package/latticeai/models/router.py +0 -1007
  225. package/latticeai/runtime/build_phases.py +0 -1450
  226. package/latticeai/services/brain_intelligence.py +0 -1083
  227. package/latticeai/services/memory_service.py +0 -1177
  228. package/latticeai/services/model_runtime.py +0 -1281
  229. package/latticeai/setup/wizard.py +0 -1310
  230. package/static/app/assets/Act-AWf0SAKp.js +0 -1
  231. package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
  232. package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
  233. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
  234. package/static/app/assets/Capture-CqOSzyPr.js +0 -1
  235. package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
  236. package/static/app/assets/Library-CX-bbhmK.js +0 -1
  237. package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
  238. package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
  239. package/static/app/assets/System-Bu2t5hn1.js +0 -1
  240. package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
  241. package/static/app/assets/brain-DJMoqrwx.js +0 -1
  242. package/static/app/assets/index-BpYkzcVm.js +0 -10
  243. package/static/app/assets/input-DSlJJxRs.js +0 -1
  244. package/static/app/assets/primitives-BCx6TvfG.js +0 -1
  245. package/static/app/assets/search-Cgy8cCFJ.js +0 -1
  246. package/static/app/assets/utils-zqPZJxdx.js +0 -4
  247. package/static/app/assets/workspace-DXTihhfU.js +0 -1
@@ -0,0 +1,478 @@
1
+ """One question, end to end: ask, render honestly, and get approval before acting.
2
+
3
+ The conversational half of the bridge. :func:`ask_ai` is the single door to the
4
+ Lattice server for a user's message; everything else in this module renders what
5
+ came back or negotiates what happens next:
6
+
7
+ * **honest rendering** — the artifact card, the grounding badge and the run
8
+ explanation report exactly the flags the server issued. An absent flag is
9
+ never promoted into a claim (SURFACE_PARITY, v9.9.6/9.9.7).
10
+ * **Review Center** — staged change proposals with inline approve/reject over
11
+ the same ``/api/proposals`` surface the web app uses.
12
+ * **plan approval** — the human-in-the-loop pause. ``_bot_pending_plans`` is the
13
+ bridge's only mutable module state and lives *here*, beside the two functions
14
+ that write and consume it, so a stub or a reset reaches the real dictionary.
15
+
16
+ Stubbing note: ``_server_client``, ``ask_ai``, ``send_message``,
17
+ ``send_chat_action``, ``send_generated_files``, ``send_preview_links``,
18
+ ``send_run_explanation`` and ``send_artifact_card`` are read as *this* module's
19
+ globals, so a test standing in for any of them patches this module.
20
+ """
21
+
22
+ import json
23
+ from typing import Any, Dict
24
+
25
+ from latticeai.core.logging_safety import safe_log_text
26
+ from latticeai.core.quiet import quiet
27
+
28
+ from .config import (
29
+ AGENT_RESUME_URL,
30
+ AGENT_URL,
31
+ CHAT_URL,
32
+ PROPOSALS_URL,
33
+ _server_client,
34
+ logger,
35
+ )
36
+ from .helpers import (
37
+ collect_generated_files,
38
+ collect_preview_urls,
39
+ send_chat_action,
40
+ send_generated_files,
41
+ send_message,
42
+ send_preview_links,
43
+ )
44
+
45
+ # ── AI chat ───────────────────────────────────────────────────────────────────
46
+
47
+ async def ask_ai(client, message, image_data=None, agent_mode=False,
48
+ planning_model=None, executing_model=None, reviewing_model=None):
49
+ try:
50
+ if agent_mode and not image_data:
51
+ url = AGENT_URL
52
+ payload = {
53
+ "message": message, "source": "telegram",
54
+ "human_in_loop": True,
55
+ "planning_model": planning_model,
56
+ "executing_model": executing_model,
57
+ "reviewing_model": reviewing_model,
58
+ }
59
+ else:
60
+ url = CHAT_URL
61
+ payload = {"message": message, "source": "telegram", "stream": False}
62
+ if image_data:
63
+ payload["image_data"] = image_data
64
+ async with _server_client() as sc:
65
+ res = await sc.post(url, json=payload, timeout=300.0)
66
+ if res.status_code == 200:
67
+ ct = res.headers.get("content-type", "")
68
+ if "text/event-stream" in ct:
69
+ text = ""
70
+ for line in res.text.splitlines():
71
+ if line.startswith("data:"):
72
+ try:
73
+ chunk = json.loads(line[5:].strip()).get("chunk", "")
74
+ text += chunk
75
+ except Exception:
76
+ quiet()
77
+ return {"response": text.strip() or "⚠️ 빈 응답"}
78
+ return res.json()
79
+ try:
80
+ detail = res.json().get("detail", "")
81
+ except Exception:
82
+ detail = ""
83
+ if res.status_code == 400 and "model" in detail.lower():
84
+ return {"response": "⚠️ 로드된 모델이 없습니다. 먼저 /model 명령으로 모델을 선택해주세요."}
85
+ return {"response": f"❌ 서버 에러 ({res.status_code}){': ' + detail if detail else ''}"}
86
+ except Exception as e:
87
+ return {"response": f"❌ 서버 연결 실패: {e}"}
88
+
89
+ def format_artifact_card(data, *, language: str = "ko") -> str:
90
+ """Artifact card summary for a finished run (v10.4.0).
91
+
92
+ Telegram used to send the produced files and nothing else, so a
93
+ deterministically repaired scaffold arrived looking exactly like clean
94
+ model output — SURFACE_PARITY recorded that as ◐. This renders the same
95
+ ``artifacts[]`` flags the web cards show, and reports only what the server
96
+ said: an absent flag is never promoted into a claim of validity.
97
+ """
98
+ if not isinstance(data, dict):
99
+ return ""
100
+ artifacts = data.get("artifacts")
101
+ if not isinstance(artifacts, list) or not artifacts:
102
+ return ""
103
+ korean = not str(language or "ko").startswith("en")
104
+ lines = ["📄 만든 파일" if korean else "📄 Files produced"]
105
+ for item in artifacts[:8]:
106
+ if not isinstance(item, dict):
107
+ continue
108
+ path = str(item.get("path") or "").strip()
109
+ if not path:
110
+ continue
111
+ notes = []
112
+ size = item.get("bytes")
113
+ if isinstance(size, (int, float)) and size > 0:
114
+ notes.append(f"{int(size):,} B")
115
+ if item.get("repaired") is True:
116
+ notes.append("자동 보정됨" if korean else "auto-repaired")
117
+ if item.get("valid") is False:
118
+ notes.append("검증 실패" if korean else "failed validation")
119
+ suffix = f" ({' · '.join(notes)})" if notes else ""
120
+ lines.append(f"• {path}{suffix}")
121
+ extra = len(artifacts) - 8
122
+ if extra > 0:
123
+ lines.append(f"… +{extra}")
124
+ return "\n".join(lines) if len(lines) > 1 else ""
125
+
126
+
127
+ async def send_artifact_card(client, chat_id, data, *, language: str = "ko") -> None:
128
+ text = format_artifact_card(data, language=language)
129
+ if text:
130
+ await send_message(client, chat_id, text)
131
+
132
+
133
+ def format_grounding_badge(data, *, language: str = "ko") -> str:
134
+ """Answer-grounding badge line for a `/chat` reply (v9.9.7).
135
+
136
+ Telegram used to show only the answer text, so a reply the Brain could not
137
+ ground looked identical to one built on real sources. This renders exactly
138
+ the verdict the server issued — and reports an absent verdict as unknown
139
+ rather than promoting it to "근거 있음".
140
+ """
141
+ if not isinstance(data, dict):
142
+ return ""
143
+ grounding = data.get("grounding")
144
+ if not isinstance(grounding, dict):
145
+ return ""
146
+ status = str(grounding.get("status") or "")
147
+ if not status:
148
+ return ""
149
+ korean = not str(language or "ko").startswith("en")
150
+ cited = [
151
+ str(item.get("title") or "").strip()
152
+ for item in (grounding.get("cited") or [])
153
+ if isinstance(item, dict) and str(item.get("title") or "").strip()
154
+ ]
155
+ if status == "supported":
156
+ head = "✅ 근거 있음" if korean else "✅ grounded"
157
+ if cited:
158
+ head += " — " + ", ".join(cited[:3])
159
+ return head
160
+ if status in {"unsupported", "no_context"}:
161
+ head = "⚠️ 근거 없음" if korean else "⚠️ not grounded"
162
+ reason = str(grounding.get("reason") or "").strip()
163
+ return f"{head} — {reason}" if reason else head
164
+ return "❔ 근거 확인 불가" if korean else "❔ grounding unknown"
165
+
166
+
167
+ async def send_grounding_badge(client, chat_id, data):
168
+ text = format_grounding_badge(data)
169
+ if text:
170
+ await send_message(client, chat_id, text)
171
+
172
+
173
+ def format_proposals(payload) -> list:
174
+ """`GET /api/proposals` → ``[(item_id, label)]`` for the Review Center.
175
+
176
+ Rows without an id are dropped: an un-actionable row is worse than none.
177
+ """
178
+ root = payload if isinstance(payload, dict) else {}
179
+ rows = payload if isinstance(payload, list) else (root.get("items") or [])
180
+ out = []
181
+ for raw in rows if isinstance(rows, list) else []:
182
+ if not isinstance(raw, dict):
183
+ continue
184
+ item_id = str(raw.get("id") or "").strip()
185
+ if not item_id:
186
+ continue
187
+ raw_body = raw.get("payload")
188
+ raw_prov = raw.get("provenance")
189
+ body: Dict[str, Any] = raw_body if isinstance(raw_body, dict) else {}
190
+ provenance: Dict[str, Any] = raw_prov if isinstance(raw_prov, dict) else {}
191
+ path = str(body.get("path") or provenance.get("path") or "")
192
+ title = str(raw.get("title") or path or item_id)
193
+ change_class = str(body.get("change_class") or provenance.get("change_class") or "")
194
+ label = title if not change_class else f"{title} ({change_class})"
195
+ out.append((item_id, label))
196
+ return out
197
+
198
+
199
+ async def show_review_center(client, chat_id):
200
+ """Review Center parity (v9.9.7): staged change proposals with inline
201
+ approve/reject, using the same `/api/proposals` surface as the web app."""
202
+ try:
203
+ async with _server_client() as sc:
204
+ res = await sc.get(PROPOSALS_URL, timeout=30.0)
205
+ if res.status_code != 200:
206
+ await send_message(client, chat_id, f"검토함을 불러오지 못했습니다 ({res.status_code})")
207
+ return
208
+ items = format_proposals(res.json())
209
+ except Exception as exc:
210
+ await send_message(client, chat_id, f"검토함 조회 실패: {exc}")
211
+ return
212
+ if not items:
213
+ await send_message(client, chat_id, "🗂 검토할 변경 제안이 없습니다.")
214
+ return
215
+ lines = ["🗂 변경 제안 (승인해야 적용됩니다)"]
216
+ keyboard = []
217
+ for item_id, label in items[:8]:
218
+ lines.append(f"- {label}")
219
+ keyboard.append([
220
+ {"text": f"✅ 승인 — {label}"[:64], "callback_data": f"proposal:approve:{item_id}"[:64]},
221
+ {"text": "❌ 거절"[:64], "callback_data": f"proposal:reject:{item_id}"[:64]},
222
+ ])
223
+ if len(items) > 8:
224
+ lines.append(f"… 외 {len(items) - 8}건")
225
+ await send_message(
226
+ client, chat_id, "\n".join(lines),
227
+ reply_markup={"inline_keyboard": keyboard},
228
+ )
229
+
230
+
231
+ async def handle_proposal_callback(client, chat_id, data: str):
232
+ """Apply or reject one staged proposal.
233
+
234
+ A 409 means the target file changed since staging — nothing was written,
235
+ and the user is told exactly that instead of a silent retry.
236
+ """
237
+ try:
238
+ _, decision, item_id = data.split(":", 2)
239
+ except ValueError:
240
+ return
241
+ if not item_id:
242
+ return
243
+ suffix = "approve" if decision == "approve" else "reject"
244
+ url = f"{PROPOSALS_URL}/{item_id}/{suffix}"
245
+ try:
246
+ async with _server_client() as sc:
247
+ res = await sc.post(url, json={}, timeout=120.0)
248
+ except Exception as exc:
249
+ await send_message(client, chat_id, f"❌ 처리 실패: {exc}")
250
+ return
251
+ if res.status_code == 409:
252
+ await send_message(
253
+ client, chat_id,
254
+ "⚠️ 제안을 만든 뒤 파일이 바뀌어서 적용하지 않았습니다. 아무것도 쓰지 않았습니다.",
255
+ )
256
+ return
257
+ if res.status_code != 200:
258
+ await send_message(client, chat_id, f"❌ 서버 에러 ({res.status_code})")
259
+ return
260
+ if suffix == "approve":
261
+ try:
262
+ applied = res.json().get("path") or item_id
263
+ except Exception:
264
+ applied = item_id
265
+ await send_message(client, chat_id, f"✅ 적용했습니다: {applied}")
266
+ else:
267
+ await send_message(client, chat_id, "🚫 제안을 거절했습니다.")
268
+
269
+
270
+ def format_run_explanation(agent_data, *, language: str = "ko") -> str:
271
+ """Plain-language outcome line for a finished agent run (v9.9.6).
272
+
273
+ Telegram used to show only the final message, so a NEEDS_REVIEW run read
274
+ exactly like a success. The server already computes the honest sentence
275
+ (`explanation`); this renders it. Returns "" when there is nothing to add
276
+ — a clean, verified run gets no extra noise.
277
+ """
278
+ if not isinstance(agent_data, dict):
279
+ return ""
280
+ explanation = agent_data.get("explanation")
281
+ if not isinstance(explanation, dict):
282
+ return ""
283
+ lang = "ko" if str(language or "ko").startswith("ko") else "en"
284
+
285
+ def pick(entry):
286
+ return str(entry.get(lang) or "") if isinstance(entry, dict) else ""
287
+
288
+ details = [pick(item) for item in explanation.get("details") or []]
289
+ details = [line for line in details if line]
290
+ if explanation.get("ok") and not details:
291
+ return ""
292
+ headline = pick(explanation.get("headline"))
293
+ marker = "✅" if explanation.get("ok") else "⚠️"
294
+ lines = [f"{marker} {headline}" if headline else marker]
295
+ lines.extend(f"· {line}" for line in details[:4])
296
+ return "\n".join(lines)
297
+
298
+
299
+ async def send_run_explanation(client, chat_id, agent_data):
300
+ text = format_run_explanation(agent_data)
301
+ if text:
302
+ await send_message(client, chat_id, text)
303
+
304
+
305
+ # ── Plan approval (Human-in-the-loop) ────────────────────────────────────────
306
+
307
+ # Pending plan approvals keyed by run_id (or legacy context_id).
308
+ # Values: chat_id, models, and the resume credentials (token and/or legacy).
309
+ _bot_pending_plans: dict[str, dict] = {}
310
+
311
+
312
+ def _approval_pause_id(data: dict) -> str:
313
+ """Stable key for a paused plan: run_id preferred, legacy context_id fallback."""
314
+ return str(data.get("run_id") or data.get("context_id") or "")
315
+
316
+
317
+ def _is_approval_pause(data: dict) -> bool:
318
+ status = str(data.get("status") or "")
319
+ return status in {"waiting_approval", "awaiting_approval"}
320
+
321
+
322
+ async def send_plan_for_approval(client, chat_id, data: dict) -> None:
323
+ """Show the agent plan to the user and present Done/Cancel buttons.
324
+
325
+ Handles both the modern ``awaiting_approval`` (run_id + token) and the
326
+ legacy ``waiting_approval`` / ``context_id`` wire contracts (v9.9.5
327
+ SURFACE_PARITY — Telegram approval flow).
328
+ """
329
+ pause_id = _approval_pause_id(data)
330
+ if not pause_id:
331
+ await send_message(client, chat_id, "❌ 승인할 계획을 식별할 수 없습니다.")
332
+ return
333
+ plan = data.get("plan", {}) or {}
334
+ goal = plan.get("goal") or (data.get("approval") or {}).get("plan_summary") or ""
335
+ steps = plan.get("steps", []) or data.get("non_auto_steps") or []
336
+ p_model = data.get("planning_model", "current")
337
+ e_model = data.get("executing_model", "current")
338
+ r_model = data.get("reviewing_model", "current")
339
+ approval = data.get("approval") or {}
340
+ expires_at = approval.get("expires_at")
341
+
342
+ lines = ["📋 *플래닝 완료* — 실행 전 확인해주세요\n"]
343
+ if goal:
344
+ lines.append(f"*목표:* {goal}\n")
345
+ for i, step in enumerate(steps, 1):
346
+ if isinstance(step, dict):
347
+ desc = step.get("description") or step.get("action") or str(step)
348
+ else:
349
+ desc = str(step)
350
+ lines.append(f"{i}. {desc}")
351
+ lines.append(f"\n🧠 플래닝: `{p_model}`")
352
+ lines.append(f"⚙️ 실행: `{e_model}`")
353
+ lines.append(f"🔍 검토: `{r_model}`")
354
+ if expires_at:
355
+ lines.append(f"⏳ 승인 만료: `{expires_at}`")
356
+
357
+ _bot_pending_plans[pause_id] = {
358
+ "chat_id": chat_id,
359
+ "run_id": data.get("run_id") or pause_id,
360
+ "context_id": data.get("context_id"),
361
+ "approval_token": approval.get("token"),
362
+ "legacy": data.get("status") == "waiting_approval" or bool(data.get("context_id")),
363
+ "executing_model": data.get("executing_model"),
364
+ "reviewing_model": data.get("reviewing_model"),
365
+ }
366
+
367
+ # callback_data max 64 bytes — pause ids are token_urlsafe(16) (~22 chars).
368
+ keyboard = {"inline_keyboard": [[
369
+ {"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{pause_id}"},
370
+ {"text": "❌ 취소", "callback_data": f"plan:cancel:{pause_id}"},
371
+ ]]}
372
+ await send_message(client, chat_id, "\n".join(lines), reply_markup=keyboard)
373
+
374
+
375
+ async def handle_plan_callback(client, chat_id, data: str) -> None:
376
+ """Handle Done/Cancel callback from plan approval buttons."""
377
+ parts = data.split(":", 2)
378
+ if len(parts) != 3:
379
+ return
380
+ _, action, pause_id = parts
381
+ pending = _bot_pending_plans.get(pause_id)
382
+
383
+ # A callback is bound to the chat that received the plan. Even two allowed
384
+ # chats cannot approve or cancel each other's plan by replaying callback
385
+ # data.
386
+ if pending and int(pending.get("chat_id") or 0) != int(chat_id):
387
+ await send_message(client, chat_id, "❌ 다른 채팅의 작업은 처리할 수 없습니다.")
388
+ return
389
+ pending = _bot_pending_plans.pop(pause_id, None)
390
+
391
+ if action == "cancel":
392
+ if pending:
393
+ try:
394
+ resume_body = _resume_payload(pending, approved=False)
395
+ async with _server_client() as sc:
396
+ await sc.post(AGENT_RESUME_URL, json=resume_body, timeout=30.0)
397
+ except Exception as exc: # noqa: BLE001 — cancel is best-effort
398
+ logger.warning("telegram cancel resume failed: %s", safe_log_text(exc))
399
+ await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
400
+ return
401
+ if not pending:
402
+ await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
403
+ return
404
+
405
+ await send_message(client, chat_id, "⚙️ 실행 중입니다. 잠시 기다려주세요...")
406
+ await send_chat_action(client, chat_id, "typing")
407
+
408
+ try:
409
+ async with _server_client() as sc:
410
+ res = await sc.post(
411
+ AGENT_RESUME_URL,
412
+ json=_resume_payload(pending, approved=True),
413
+ timeout=300.0,
414
+ )
415
+ data_r = res.json() if res.status_code == 200 else {}
416
+ ans = data_r.get("response", f"❌ 서버 에러 ({res.status_code})")
417
+ await send_message(client, chat_id, str(ans))
418
+ if isinstance(data_r, dict):
419
+ await send_run_explanation(client, chat_id, data_r)
420
+ await send_artifact_card(client, chat_id, data_r)
421
+ await send_generated_files(client, chat_id, collect_generated_files(data_r))
422
+ await send_preview_links(client, chat_id, collect_preview_urls(data_r))
423
+ except Exception as e:
424
+ await send_message(client, chat_id, f"❌ 실행 중 오류: {e}")
425
+
426
+
427
+ def _resume_payload(pending: dict, *, approved: bool) -> dict:
428
+ """Build /agent/resume body: token path preferred, else legacy context_id."""
429
+ body: dict = {
430
+ "approved": approved,
431
+ "executing_model": pending.get("executing_model"),
432
+ "reviewing_model": pending.get("reviewing_model"),
433
+ }
434
+ token = pending.get("approval_token")
435
+ run_id = pending.get("run_id")
436
+ # Unified durable store (9.9.5): token-gated resume works for both the
437
+ # modern awaiting_approval and the legacy human_in_loop pause.
438
+ if token and run_id:
439
+ body["run_id"] = run_id
440
+ body["approval_token"] = token
441
+ return body
442
+ body["context_id"] = pending.get("context_id") or run_id
443
+ return body
444
+
445
+
446
+ # ── AI request task ───────────────────────────────────────────────────────────
447
+
448
+ async def process_ai_request(client, chat_id, user_text, image_data=None):
449
+ try:
450
+ await send_chat_action(client, chat_id, "upload_photo" if image_data else "typing")
451
+ logger.info("ask_ai 호출 시작: chat_id=%s text=%r", chat_id, safe_log_text(user_text[:30]))
452
+ data = await ask_ai(client, user_text, image_data, agent_mode=not image_data)
453
+ logger.info("ask_ai 완료: chat_id=%s result_keys=%s", chat_id, list(data.keys()) if isinstance(data, dict) else type(data))
454
+
455
+ # Approval pause (legacy waiting_approval or modern awaiting_approval)
456
+ if isinstance(data, dict) and _is_approval_pause(data):
457
+ await send_plan_for_approval(client, chat_id, data)
458
+ return
459
+
460
+ ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
461
+ if not ans or not str(ans).strip():
462
+ ans = "⚠️ AI가 답변을 생성하지 못했습니다."
463
+ await send_message(client, chat_id, str(ans))
464
+ if isinstance(data, dict):
465
+ # Recall parity (v9.9.7): the same grounding verdict the web app
466
+ # badges, never promoted when the server issued none.
467
+ await send_grounding_badge(client, chat_id, data)
468
+ if not image_data and isinstance(data, dict):
469
+ await send_run_explanation(client, chat_id, data)
470
+ await send_artifact_card(client, chat_id, data)
471
+ await send_generated_files(client, chat_id, collect_generated_files(data))
472
+ await send_preview_links(client, chat_id, collect_preview_urls(data))
473
+ except Exception as e:
474
+ logger.error("process_ai_request 실패 (chat_id=%s): %s", chat_id, safe_log_text(e), exc_info=True)
475
+ try:
476
+ await send_message(client, chat_id, "⚠️ 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.")
477
+ except Exception:
478
+ quiet()