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.
- package/README.md +46 -53
- package/docs/CHANGELOG.md +61 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/MULTI_AGENT_RUNTIME.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +6 -2
- package/docs/PERMISSION_MODE.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +2 -2
- package/docs/v11.3.0_PLAN.md +202 -0
- package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +176 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common/__init__.py +287 -0
- package/lattice_brain/graph/_kg_common/extraction.py +516 -0
- package/lattice_brain/graph/_kg_common/relations.py +161 -0
- package/lattice_brain/graph/_kg_common/text.py +479 -0
- package/lattice_brain/graph/discovery_index/__init__.py +35 -0
- package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
- package/lattice_brain/graph/discovery_index/extract.py +137 -0
- package/lattice_brain/graph/discovery_index/scan.py +411 -0
- package/lattice_brain/graph/discovery_index/upsert.py +495 -0
- package/lattice_brain/graph/projection/__init__.py +42 -0
- package/lattice_brain/graph/projection/curation.py +500 -0
- package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
- package/lattice_brain/graph/retrieval/__init__.py +54 -0
- package/lattice_brain/graph/retrieval/context.py +197 -0
- package/lattice_brain/graph/retrieval/graph_view.py +319 -0
- package/lattice_brain/graph/retrieval/hybrid.py +488 -0
- package/lattice_brain/graph/retrieval/maintenance.py +121 -0
- package/lattice_brain/graph/retrieval/signals.py +95 -0
- package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
- package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
- package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
- package/lattice_brain/graph/retrieval_vector/search.py +560 -0
- package/lattice_brain/graph/retrieval_vector/status.py +374 -0
- package/lattice_brain/ingestion/__init__.py +130 -0
- package/lattice_brain/ingestion/_contract.py +90 -0
- package/lattice_brain/ingestion/constants.py +127 -0
- package/lattice_brain/ingestion/folder_scan.py +57 -0
- package/lattice_brain/ingestion/folders.py +258 -0
- package/lattice_brain/ingestion/hashing.py +26 -0
- package/lattice_brain/ingestion/jobs_api.py +107 -0
- package/lattice_brain/ingestion/models.py +80 -0
- package/lattice_brain/ingestion/pipeline.py +486 -0
- package/lattice_brain/ingestion/quality.py +209 -0
- package/lattice_brain/ingestion/routing.py +295 -0
- package/lattice_brain/multimodal/__init__.py +164 -0
- package/lattice_brain/multimodal/audio.py +77 -0
- package/lattice_brain/multimodal/common.py +118 -0
- package/lattice_brain/multimodal/images.py +498 -0
- package/lattice_brain/multimodal/ports.py +169 -0
- package/lattice_brain/multimodal/video.py +410 -0
- package/lattice_brain/portability/__init__.py +90 -0
- package/lattice_brain/portability/_contract.py +42 -0
- package/lattice_brain/portability/backups.py +338 -0
- package/lattice_brain/portability/bundles.py +136 -0
- package/lattice_brain/portability/constants.py +93 -0
- package/lattice_brain/portability/fsops.py +138 -0
- package/lattice_brain/portability/service.py +41 -0
- package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
- package/lattice_brain/runtime/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chronicle.py +63 -0
- package/latticeai/core/agent/__init__.py +93 -0
- package/latticeai/core/agent/_contract.py +79 -0
- package/latticeai/core/agent/context.py +57 -0
- package/latticeai/core/agent/deps.py +125 -0
- package/latticeai/core/agent/execution.py +622 -0
- package/latticeai/core/agent/planning.py +145 -0
- package/latticeai/core/agent/recovery.py +157 -0
- package/latticeai/core/agent/runtime.py +210 -0
- package/latticeai/core/agent/verification.py +231 -0
- package/latticeai/core/embedding_providers/__init__.py +151 -0
- package/latticeai/core/embedding_providers/base.py +199 -0
- package/latticeai/core/embedding_providers/captions.py +162 -0
- package/latticeai/core/embedding_providers/profiles.py +126 -0
- package/latticeai/core/embedding_providers/text.py +350 -0
- package/latticeai/core/embedding_providers/vision.py +352 -0
- package/latticeai/core/file_generation/__init__.py +115 -0
- package/latticeai/core/file_generation/bundles.py +76 -0
- package/latticeai/core/file_generation/extraction.py +154 -0
- package/latticeai/core/file_generation/inference.py +235 -0
- package/latticeai/core/file_generation/orchestration.py +152 -0
- package/latticeai/core/file_generation/prompting.py +117 -0
- package/latticeai/core/file_generation/repair.py +114 -0
- package/latticeai/core/file_generation/sanitize.py +61 -0
- package/latticeai/core/file_generation/validation.py +201 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +9 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/integrations/telegram_bot/__init__.py +123 -0
- package/latticeai/integrations/telegram_bot/__main__.py +17 -0
- package/latticeai/integrations/telegram_bot/config.py +86 -0
- package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
- package/latticeai/integrations/telegram_bot/flows.py +478 -0
- package/latticeai/integrations/telegram_bot/helpers.py +322 -0
- package/latticeai/integrations/telegram_bot/screens.py +394 -0
- package/latticeai/models/router/__init__.py +88 -0
- package/latticeai/models/router/_contract.py +66 -0
- package/latticeai/models/router/branding.py +56 -0
- package/latticeai/models/router/catalog.py +69 -0
- package/latticeai/models/router/documents.py +199 -0
- package/latticeai/models/router/errors.py +37 -0
- package/latticeai/models/router/generation.py +258 -0
- package/latticeai/models/router/loading.py +291 -0
- package/latticeai/models/router/local_models.py +85 -0
- package/latticeai/models/router/registry.py +147 -0
- package/latticeai/runtime/build_phases/__init__.py +82 -0
- package/latticeai/runtime/build_phases/features.py +407 -0
- package/latticeai/runtime/build_phases/foundation.py +555 -0
- package/latticeai/runtime/build_phases/web.py +492 -0
- package/latticeai/runtime/runtime_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +48 -19
- package/latticeai/services/brain_intelligence/__init__.py +58 -0
- package/latticeai/services/brain_intelligence/_contract.py +71 -0
- package/latticeai/services/brain_intelligence/consistency.py +193 -0
- package/latticeai/services/brain_intelligence/constants.py +47 -0
- package/latticeai/services/brain_intelligence/digest.py +258 -0
- package/latticeai/services/brain_intelligence/health.py +331 -0
- package/latticeai/services/brain_intelligence/proposals.py +264 -0
- package/latticeai/services/brain_intelligence/sampling.py +84 -0
- package/latticeai/services/brain_intelligence/service.py +48 -0
- package/latticeai/services/chronicle.py +557 -0
- package/latticeai/services/memory_service/__init__.py +52 -0
- package/latticeai/services/memory_service/_contract.py +100 -0
- package/latticeai/services/memory_service/brief.py +431 -0
- package/latticeai/services/memory_service/constants.py +57 -0
- package/latticeai/services/memory_service/maintenance.py +138 -0
- package/latticeai/services/memory_service/manager.py +186 -0
- package/latticeai/services/memory_service/proof.py +136 -0
- package/latticeai/services/memory_service/recall.py +225 -0
- package/latticeai/services/memory_service/service.py +48 -0
- package/latticeai/services/memory_service/stores.py +110 -0
- package/latticeai/services/model_runtime/__init__.py +322 -0
- package/latticeai/services/model_runtime/cloud.py +87 -0
- package/latticeai/services/model_runtime/download.py +282 -0
- package/latticeai/services/model_runtime/engines.py +341 -0
- package/latticeai/services/model_runtime/loading.py +178 -0
- package/latticeai/services/model_runtime/service.py +129 -0
- package/latticeai/services/model_runtime/state.py +131 -0
- package/latticeai/services/model_runtime/status.py +255 -0
- package/latticeai/services/product_readiness.py +15 -7
- package/latticeai/setup/wizard/__init__.py +126 -0
- package/latticeai/setup/wizard/catalog.py +172 -0
- package/latticeai/setup/wizard/detect.py +323 -0
- package/latticeai/setup/wizard/install.py +348 -0
- package/latticeai/setup/wizard/paths.py +168 -0
- package/latticeai/setup/wizard/plans.py +74 -0
- package/latticeai/setup/wizard/recommend.py +320 -0
- package/package.json +6 -2
- package/scripts/bump_version.py +14 -0
- package/scripts/capture_release_evidence.mjs +33 -21
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_i18n_namespace_coverage.mjs +41 -4
- package/scripts/check_max_file_lines.mjs +102 -0
- package/scripts/check_release_evidence_bound.mjs +30 -15
- package/scripts/check_screenshot_pixel_delta.py +34 -4
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/generate_rust_parity_fixtures.py +562 -0
- package/scripts/lib/mock_server_fingerprint.mjs +94 -0
- package/scripts/release_screen_claims.json +31 -2
- package/src-tauri/Cargo.lock +361 -3
- package/src-tauri/Cargo.toml +6 -1
- package/src-tauri/src/backend.rs +349 -0
- package/src-tauri/src/folder.rs +33 -0
- package/src-tauri/src/main.rs +97 -399
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +41 -37
- package/static/app/assets/Act-yYpYnn0v.js +1 -0
- package/static/app/assets/AdminConsole-DL3Cr5pL.js +1 -0
- package/static/app/assets/{Brain-tuhI4sOC.js → Brain-C1HBN0Wf.js} +2 -2
- package/static/app/assets/BrainHome-DoXRhUUC.js +2 -0
- package/static/app/assets/BrainSignals-6yR6ir5t.js +1 -0
- package/static/app/assets/Capture-CFIRsFNE.js +1 -0
- package/static/app/assets/Chronicle-BZbEgiwN.js +1 -0
- package/static/app/assets/CommandPalette-D2pMxC2I.js +1 -0
- package/static/app/assets/Library-DwO3yZST.js +1 -0
- package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-Jn1GK0-S.js} +1 -1
- package/static/app/assets/ProductFlow-B-w1R4Oo.js +1 -0
- package/static/app/assets/ReviewCard-6B27X8Vg.js +3 -0
- package/static/app/assets/System-DW8F-2xL.js +1 -0
- package/static/app/assets/arrow-left-DXvKg9U6.js +1 -0
- package/static/app/assets/{bot-Cia42c2h.js → bot-IM_E_Y12.js} +1 -1
- package/static/app/assets/brain-Ci1CkWjM.js +1 -0
- package/static/app/assets/{button-2j2Ijzgq.js → button-COwyqfHM.js} +1 -1
- package/static/app/assets/circle-check-DfInj-qD.js +1 -0
- package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-DEM4A1Y5.js} +1 -1
- package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-C9djDuLd.js} +1 -1
- package/static/app/assets/{cpu-k4awryFq.js → cpu-DFdo1gw-.js} +1 -1
- package/static/app/assets/{download-DFbLJ_ig.js → download-SnJL6oqk.js} +1 -1
- package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CqZeDkjE.js} +1 -1
- package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-j1jJXYYf.js} +1 -1
- package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
- package/static/app/assets/index-_u5iUHDr.js +10 -0
- package/static/app/assets/input-B0lPdRQZ.js +1 -0
- package/static/app/assets/link-2-CoFbooHS.js +1 -0
- package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-BsyLxtao.js} +1 -1
- package/static/app/assets/primitives-DEbN-d6p.js +1 -0
- package/static/app/assets/search-BybIWPNd.js +1 -0
- package/static/app/assets/{share-2-BH1M-WNi.js → share-2-CVtZ_ewX.js} +1 -1
- package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-CBi2GNWM.js} +1 -1
- package/static/app/assets/{textarea-CCWbUfFB.js → textarea-DNMpB5ih.js} +1 -1
- package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-C83t3GXF.js} +1 -1
- package/static/app/assets/useMutation-DtbJDoyz.js +1 -0
- package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-Dcp1OChy.js} +1 -1
- package/static/app/assets/utils-BlZr7Pd4.js +4 -0
- package/static/app/assets/workspace-jJY4RuAV.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/graph/_kg_common.py +0 -1331
- package/lattice_brain/graph/discovery_index.py +0 -1141
- package/lattice_brain/graph/retrieval.py +0 -1120
- package/lattice_brain/graph/retrieval_vector.py +0 -1293
- package/lattice_brain/ingestion.py +0 -1525
- package/lattice_brain/multimodal.py +0 -1258
- package/latticeai/core/agent.py +0 -1465
- package/latticeai/core/embedding_providers.py +0 -1196
- package/latticeai/core/file_generation.py +0 -1047
- package/latticeai/integrations/telegram_bot.py +0 -1390
- package/latticeai/models/router.py +0 -1007
- package/latticeai/runtime/build_phases.py +0 -1450
- package/latticeai/services/brain_intelligence.py +0 -1083
- package/latticeai/services/memory_service.py +0 -1177
- package/latticeai/services/model_runtime.py +0 -1281
- package/latticeai/setup/wizard.py +0 -1310
- package/static/app/assets/Act-AWf0SAKp.js +0 -1
- package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
- package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
- package/static/app/assets/Capture-CqOSzyPr.js +0 -1
- package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
- package/static/app/assets/Library-CX-bbhmK.js +0 -1
- package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
- package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
- package/static/app/assets/System-Bu2t5hn1.js +0 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
- package/static/app/assets/brain-DJMoqrwx.js +0 -1
- package/static/app/assets/index-BpYkzcVm.js +0 -10
- package/static/app/assets/input-DSlJJxRs.js +0 -1
- package/static/app/assets/primitives-BCx6TvfG.js +0 -1
- package/static/app/assets/search-Cgy8cCFJ.js +0 -1
- package/static/app/assets/utils-zqPZJxdx.js +0 -4
- 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()
|