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.
- package/README.md +50 -53
- package/docs/CHANGELOG.md +87 -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 +181 -0
- package/docs/v11.5.0_RUST_COMPLETE_PLAN.md +145 -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/api/index_jobs.py +145 -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 +14 -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 +421 -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 +2 -0
- package/scripts/chunking_parity_corpus.py +449 -0
- package/scripts/generate_agent_parity_fixtures.py +752 -0
- package/scripts/generate_chunking_parity_fixtures.py +259 -0
- package/scripts/generate_rust_parity_fixtures.py +997 -0
- package/scripts/lib/mock_server_fingerprint.mjs +94 -0
- package/scripts/release_screen_claims.json +42 -2
- package/src-tauri/Cargo.lock +404 -3
- package/src-tauri/Cargo.toml +13 -1
- package/src-tauri/src/backend.rs +460 -0
- package/src-tauri/src/folder.rs +33 -0
- package/src-tauri/src/main.rs +109 -399
- package/src-tauri/src/topology.rs +356 -0
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +41 -37
- package/static/app/assets/Act-CWnxSCgN.js +1 -0
- package/static/app/assets/AdminConsole-BEQYU6kF.js +1 -0
- package/static/app/assets/{Brain-tuhI4sOC.js → Brain-DWu1BhFg.js} +2 -2
- package/static/app/assets/BrainHome-95Hilr9R.js +2 -0
- package/static/app/assets/BrainSignals-QdeqCpAF.js +1 -0
- package/static/app/assets/Capture-BHpCxnzb.js +1 -0
- package/static/app/assets/Chronicle-B4xYKoed.js +1 -0
- package/static/app/assets/CommandPalette-BVXnttSz.js +1 -0
- package/static/app/assets/Library-DgYcHome.js +1 -0
- package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-CrJLDbf7.js} +1 -1
- package/static/app/assets/ProductFlow-DFlScKoJ.js +1 -0
- package/static/app/assets/ReviewCard-Cy5f48Pj.js +3 -0
- package/static/app/assets/System-NF8IfhTa.js +1 -0
- package/static/app/assets/arrow-left-DwkSYrjR.js +1 -0
- package/static/app/assets/{bot-Cia42c2h.js → bot-CucuhLhm.js} +1 -1
- package/static/app/assets/brain-BBnSryW_.js +1 -0
- package/static/app/assets/{button-2j2Ijzgq.js → button-C2GUj2Ai.js} +1 -1
- package/static/app/assets/circle-check-CxOVPwYq.js +1 -0
- package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-CbkWzBmG.js} +1 -1
- package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-7lEaqHdJ.js} +1 -1
- package/static/app/assets/{cpu-k4awryFq.js → cpu-DAlCXlIy.js} +1 -1
- package/static/app/assets/{download-DFbLJ_ig.js → download-RNhuuJwh.js} +1 -1
- package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CLW4odzM.js} +1 -1
- package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-NKEiDIAJ.js} +1 -1
- package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
- package/static/app/assets/index-DMurvUuR.js +10 -0
- package/static/app/assets/input-D2UhPC1X.js +1 -0
- package/static/app/assets/link-2-6amKbP_P.js +1 -0
- package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-Cu9TZtdR.js} +1 -1
- package/static/app/assets/primitives-gPsccucr.js +1 -0
- package/static/app/assets/search-Cj_TKk_2.js +1 -0
- package/static/app/assets/{share-2-BH1M-WNi.js → share-2-Bau7KkPq.js} +1 -1
- package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-BufNYypi.js} +1 -1
- package/static/app/assets/{textarea-CCWbUfFB.js → textarea-BQnVWhYs.js} +1 -1
- package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-B3_w60si.js} +1 -1
- package/static/app/assets/useMutation-BHhCflT6.js +1 -0
- package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-rBWfI-5t.js} +1 -1
- package/static/app/assets/utils-V_5-wxr5.js +4 -0
- package/static/app/assets/workspace-K1zjYUHj.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
|
@@ -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()
|