answer42 0.2.0__py3-none-any.whl
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.
- answer42-0.2.0.dist-info/METADATA +388 -0
- answer42-0.2.0.dist-info/RECORD +28 -0
- answer42-0.2.0.dist-info/WHEEL +4 -0
- answer42-0.2.0.dist-info/entry_points.txt +2 -0
- answer42-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_1c/__init__.py +4 -0
- mcp_1c/assets/MCPTestClient.cf +0 -0
- mcp_1c/assets/MCPTestManager.cf +0 -0
- mcp_1c/assets/__init__.py +1 -0
- mcp_1c/assets/skills/answer42/SKILL.md +170 -0
- mcp_1c/assets/skills/answer42-rag/SKILL.md +58 -0
- mcp_1c/bridge.py +136 -0
- mcp_1c/credentials.py +147 -0
- mcp_1c/os_support.py +224 -0
- mcp_1c/platform.py +187 -0
- mcp_1c/protocol.py +35 -0
- mcp_1c/rag/__init__.py +5 -0
- mcp_1c/rag/detect.py +23 -0
- mcp_1c/rag/model.py +114 -0
- mcp_1c/rag/parsers.py +387 -0
- mcp_1c/rag/service.py +375 -0
- mcp_1c/rag/store.py +228 -0
- mcp_1c/recorder.py +239 -0
- mcp_1c/release_helper.py +83 -0
- mcp_1c/runtime.py +636 -0
- mcp_1c/server.py +3285 -0
- mcp_1c/skill_installer.py +127 -0
- mcp_1c/window_control.py +276 -0
mcp_1c/os_support.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Cross-platform OS helpers for Answer42 runtime.
|
|
2
|
+
|
|
3
|
+
Keep platform-specific process, lock and filesystem defaults here so the
|
|
4
|
+
higher-level MCP tools do not import POSIX-only modules on Windows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterator
|
|
17
|
+
|
|
18
|
+
IS_WINDOWS = os.name == "nt"
|
|
19
|
+
IS_POSIX = os.name == "posix"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_data_dir() -> Path:
|
|
23
|
+
"""Return the default Answer42 runtime data directory for this OS."""
|
|
24
|
+
override = os.getenv("ONEC_MCP_DATA_DIR")
|
|
25
|
+
if override:
|
|
26
|
+
return Path(override)
|
|
27
|
+
return Path(tempfile.gettempdir()) / "onec-mcp"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def runtime_path(env_name: str, *parts: str) -> Path:
|
|
31
|
+
"""Return an env-overridable path under the runtime data directory."""
|
|
32
|
+
override = os.getenv(env_name)
|
|
33
|
+
if override:
|
|
34
|
+
return Path(override)
|
|
35
|
+
return default_data_dir().joinpath(*parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@contextmanager
|
|
39
|
+
def file_lock(path: Path) -> Iterator[None]:
|
|
40
|
+
"""Acquire an exclusive file lock on POSIX and Windows.
|
|
41
|
+
|
|
42
|
+
The implementation imports fcntl/msvcrt lazily so importing Answer42 on
|
|
43
|
+
Windows does not fail on the POSIX-only ``fcntl`` module.
|
|
44
|
+
"""
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
with path.open("a+b") as lock_file:
|
|
47
|
+
lock_file.seek(0)
|
|
48
|
+
if not lock_file.read(1):
|
|
49
|
+
lock_file.write(b"0")
|
|
50
|
+
lock_file.flush()
|
|
51
|
+
lock_file.seek(0)
|
|
52
|
+
if IS_WINDOWS:
|
|
53
|
+
import msvcrt # type: ignore
|
|
54
|
+
|
|
55
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
|
56
|
+
try:
|
|
57
|
+
yield
|
|
58
|
+
finally:
|
|
59
|
+
lock_file.seek(0)
|
|
60
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
61
|
+
else:
|
|
62
|
+
import fcntl # type: ignore
|
|
63
|
+
|
|
64
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
65
|
+
try:
|
|
66
|
+
yield
|
|
67
|
+
finally:
|
|
68
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def subprocess_creation_kwargs(*, detached: bool = True) -> dict[str, object]:
|
|
72
|
+
"""Return Popen kwargs for a separately controllable child process."""
|
|
73
|
+
if IS_WINDOWS:
|
|
74
|
+
flags = 0
|
|
75
|
+
if detached:
|
|
76
|
+
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
77
|
+
return {"creationflags": flags} if flags else {}
|
|
78
|
+
return {"start_new_session": True} if detached else {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def kill_process_tree(proc: subprocess.Popen | None, label: str, logger: logging.Logger | None = None) -> None:
|
|
82
|
+
"""Terminate a subprocess tree in a cross-platform way."""
|
|
83
|
+
log = logger or logging.getLogger(__name__)
|
|
84
|
+
if proc is None:
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
if proc.poll() is not None:
|
|
88
|
+
return
|
|
89
|
+
except Exception:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if IS_WINDOWS:
|
|
93
|
+
try:
|
|
94
|
+
subprocess.run(
|
|
95
|
+
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
|
96
|
+
stdout=subprocess.DEVNULL,
|
|
97
|
+
stderr=subprocess.DEVNULL,
|
|
98
|
+
check=False,
|
|
99
|
+
timeout=10,
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
proc.wait(timeout=5)
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
pass
|
|
105
|
+
log.info("Stopped %s (pid=%s) via taskkill", label, proc.pid)
|
|
106
|
+
return
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
log.debug("taskkill failed for %s (pid=%s): %s", label, proc.pid, exc)
|
|
109
|
+
try:
|
|
110
|
+
proc.terminate()
|
|
111
|
+
proc.wait(timeout=5)
|
|
112
|
+
log.info("Stopped %s (pid=%s) via terminate", label, proc.pid)
|
|
113
|
+
return
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
try:
|
|
117
|
+
proc.kill()
|
|
118
|
+
proc.wait(timeout=5)
|
|
119
|
+
log.warning("Killed %s (pid=%s)", label, proc.pid)
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
log.error("Failed to stop %s (pid=%s): %s", label, proc.pid, exc)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
import signal
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
128
|
+
except ProcessLookupError:
|
|
129
|
+
return
|
|
130
|
+
except Exception:
|
|
131
|
+
try:
|
|
132
|
+
proc.terminate()
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
try:
|
|
136
|
+
proc.wait(timeout=5)
|
|
137
|
+
log.info("Stopped %s (pid=%s) via SIGTERM", label, proc.pid)
|
|
138
|
+
return
|
|
139
|
+
except subprocess.TimeoutExpired:
|
|
140
|
+
pass
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
try:
|
|
144
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
145
|
+
proc.wait(timeout=5)
|
|
146
|
+
log.warning("Killed %s (pid=%s) via SIGKILL", label, proc.pid)
|
|
147
|
+
except ProcessLookupError:
|
|
148
|
+
return
|
|
149
|
+
except subprocess.TimeoutExpired:
|
|
150
|
+
log.error("Failed to stop %s (pid=%s)", label, proc.pid)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def kill_processes_by_command_substrings(
|
|
154
|
+
substrings: list[str],
|
|
155
|
+
label: str,
|
|
156
|
+
logger: logging.Logger | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Best-effort cleanup of stale child processes matching command-line substrings."""
|
|
159
|
+
patterns = [s for s in substrings if s]
|
|
160
|
+
if not patterns:
|
|
161
|
+
return
|
|
162
|
+
log = logger or logging.getLogger(__name__)
|
|
163
|
+
if IS_WINDOWS:
|
|
164
|
+
patterns_json = json.dumps(patterns, ensure_ascii=False)
|
|
165
|
+
script = r"""
|
|
166
|
+
$patterns = ConvertFrom-Json @'
|
|
167
|
+
%s
|
|
168
|
+
'@
|
|
169
|
+
$own = %d
|
|
170
|
+
Get-CimInstance Win32_Process | Where-Object {
|
|
171
|
+
if ($_.ProcessId -eq $own -or -not $_.CommandLine) { return $false }
|
|
172
|
+
$cmd = $_.CommandLine
|
|
173
|
+
foreach ($p in $patterns) {
|
|
174
|
+
if ($p -and $cmd -like ('*' + $p + '*')) { return $true }
|
|
175
|
+
}
|
|
176
|
+
return $false
|
|
177
|
+
} | ForEach-Object {
|
|
178
|
+
try { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } catch {}
|
|
179
|
+
}
|
|
180
|
+
""" % (patterns_json, os.getpid())
|
|
181
|
+
try:
|
|
182
|
+
subprocess.run(
|
|
183
|
+
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
|
184
|
+
stdout=subprocess.DEVNULL,
|
|
185
|
+
stderr=subprocess.DEVNULL,
|
|
186
|
+
check=False,
|
|
187
|
+
timeout=15,
|
|
188
|
+
)
|
|
189
|
+
log.info("Requested cleanup of stale %s processes by command substrings", label)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
log.debug("Failed to cleanup stale %s processes: %s", label, exc)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
completed = subprocess.run(
|
|
196
|
+
["pgrep", "-f", "|".join(patterns)],
|
|
197
|
+
capture_output=True,
|
|
198
|
+
text=True,
|
|
199
|
+
check=False,
|
|
200
|
+
timeout=10,
|
|
201
|
+
)
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
log.debug("pgrep failed for stale %s cleanup: %s", label, exc)
|
|
204
|
+
return
|
|
205
|
+
own = {os.getpid(), os.getppid()}
|
|
206
|
+
for raw_pid in completed.stdout.split():
|
|
207
|
+
try:
|
|
208
|
+
pid = int(raw_pid)
|
|
209
|
+
except ValueError:
|
|
210
|
+
continue
|
|
211
|
+
if pid in own:
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
os.kill(pid, 9)
|
|
215
|
+
log.info("Killed stale %s process pid=%s", label, pid)
|
|
216
|
+
except ProcessLookupError:
|
|
217
|
+
pass
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
log.debug("Failed to kill stale %s process pid=%s: %s", label, pid, exc)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def executable_name(base: str) -> str:
|
|
223
|
+
"""Return executable name with .exe suffix on Windows."""
|
|
224
|
+
return f"{base}.exe" if IS_WINDOWS and not base.lower().endswith(".exe") else base
|
mcp_1c/platform.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""1С platform discovery helpers.
|
|
2
|
+
|
|
3
|
+
* detect_version_from_url - fetch the public login page and parse VERSION.
|
|
4
|
+
* find_platform_dir - locate the requested (or latest) platform dir.
|
|
5
|
+
* require_websocket_support - reject unsupported 1C platform versions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import socket
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
18
|
+
|
|
19
|
+
from . import os_support
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find_free_display(start: int = 99) -> str:
|
|
23
|
+
"""Find the first free Xvfb display starting from `start`."""
|
|
24
|
+
for n in range(start, 200):
|
|
25
|
+
path = f"/tmp/.X11-unix/X{n}"
|
|
26
|
+
if not Path(path).exists():
|
|
27
|
+
return f":{n}"
|
|
28
|
+
raise RuntimeError("No free X display found")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_free_port(start: int = 8765, end: int | None = None) -> int:
|
|
32
|
+
"""Find a free TCP port in the range `start..start+500` (or `start..end`)."""
|
|
33
|
+
end = end or start + 500
|
|
34
|
+
for port in range(start, end):
|
|
35
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
36
|
+
try:
|
|
37
|
+
s.bind(("", port))
|
|
38
|
+
return port
|
|
39
|
+
except OSError:
|
|
40
|
+
continue
|
|
41
|
+
raise RuntimeError(f"No free port found in range {start}..{end}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_LOCALE_RE = re.compile(r"^[a-z]{2}_[A-Z]{2}$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_infobase_url(url: str) -> str:
|
|
48
|
+
"""Return a canonical web infobase URL: no language suffix, trailing slash."""
|
|
49
|
+
value = str(url or "").strip()
|
|
50
|
+
parts = urlsplit(value)
|
|
51
|
+
if parts.scheme.lower() not in {"http", "https"}:
|
|
52
|
+
return value
|
|
53
|
+
segments = [segment for segment in parts.path.split("/") if segment]
|
|
54
|
+
if segments and _LOCALE_RE.match(segments[-1]):
|
|
55
|
+
segments.pop()
|
|
56
|
+
path = "/" + "/".join(segments) + "/" if segments else "/"
|
|
57
|
+
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def detect_version_from_url(url: str) -> Optional[str]:
|
|
61
|
+
"""Fetch a public base_url and extract VERSION from the HTML.
|
|
62
|
+
|
|
63
|
+
If the page is behind Basic Auth or simply unavailable, this is not an
|
|
64
|
+
error; we return None and let the caller pick a default platform version.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
with urllib.request.urlopen(normalize_infobase_url(url), timeout=10) as resp:
|
|
68
|
+
html = resp.read().decode("utf-8", errors="ignore")
|
|
69
|
+
m = re.search(r'var\s+VERSION\s*=\s*"(\d+\.\d+\.\d+\.\d+)"', html)
|
|
70
|
+
if m:
|
|
71
|
+
return m.group(1)
|
|
72
|
+
except (urllib.error.URLError, OSError):
|
|
73
|
+
pass
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _missing_required_tools(path: Path) -> list[str]:
|
|
80
|
+
return [
|
|
81
|
+
tool for tool in ("1cv8c", "ibcmd")
|
|
82
|
+
if not (path / os_support.executable_name(tool)).exists()
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_platform_candidate(path: Path) -> bool:
|
|
87
|
+
return not _missing_required_tools(path)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _platform_component_score(path: Path) -> tuple[int, int]:
|
|
91
|
+
"""Prefer fuller installations when the same version exists in several roots."""
|
|
92
|
+
return (
|
|
93
|
+
1 if (path / os_support.executable_name("ibsrv")).exists() else 0,
|
|
94
|
+
1 if (path / os_support.executable_name("1cv8")).exists() else 0,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def find_platform_dir(version: Optional[str] = None) -> Path:
|
|
99
|
+
"""Locate the platform directory for a given version.
|
|
100
|
+
|
|
101
|
+
If `version` is omitted the maximum available version is selected.
|
|
102
|
+
Searches standard Linux and Windows installation locations. The
|
|
103
|
+
``ONEC_PLATFORM_DIR`` environment variable can point directly to the
|
|
104
|
+
platform ``bin`` directory and takes precedence.
|
|
105
|
+
"""
|
|
106
|
+
override = os.getenv("ONEC_PLATFORM_DIR")
|
|
107
|
+
if override:
|
|
108
|
+
path = Path(override)
|
|
109
|
+
missing = _missing_required_tools(path)
|
|
110
|
+
if not missing:
|
|
111
|
+
return path
|
|
112
|
+
raise ValueError(f"ONEC_PLATFORM_DIR does not contain required 1C tools {missing}: {path}")
|
|
113
|
+
|
|
114
|
+
candidates: list[Path] = []
|
|
115
|
+
if os_support.IS_WINDOWS:
|
|
116
|
+
bases = []
|
|
117
|
+
for env_name in ("ProgramFiles", "ProgramFiles(x86)"):
|
|
118
|
+
root = os.getenv(env_name)
|
|
119
|
+
if root:
|
|
120
|
+
bases.append(Path(root) / "1cv8")
|
|
121
|
+
for base in bases:
|
|
122
|
+
if not base.exists():
|
|
123
|
+
continue
|
|
124
|
+
for sub in base.iterdir():
|
|
125
|
+
bin_dir = sub / "bin"
|
|
126
|
+
if bin_dir.is_dir() and _is_platform_candidate(bin_dir):
|
|
127
|
+
candidates.append(bin_dir)
|
|
128
|
+
elif sub.is_dir() and _is_platform_candidate(sub):
|
|
129
|
+
candidates.append(sub)
|
|
130
|
+
else:
|
|
131
|
+
for base in [Path("/opt/1cv8/x86_64"), Path("/opt/1cv8/x86"), Path("/opt/1c/x86_64"), Path("/opt/1c/x86")]:
|
|
132
|
+
if not base.exists():
|
|
133
|
+
continue
|
|
134
|
+
for sub in base.iterdir():
|
|
135
|
+
if sub.is_dir() and _is_platform_candidate(sub):
|
|
136
|
+
candidates.append(sub)
|
|
137
|
+
if not candidates:
|
|
138
|
+
raise RuntimeError(
|
|
139
|
+
"No suitable 1C platform directories found. "
|
|
140
|
+
"Answer42 requires 1cv8c and ibcmd in the platform bin directory. "
|
|
141
|
+
"Set ONEC_PLATFORM_DIR to a full platform installation."
|
|
142
|
+
)
|
|
143
|
+
if version:
|
|
144
|
+
matching = [c for c in candidates if platform_version_from_dir(c) == version]
|
|
145
|
+
if matching:
|
|
146
|
+
return max(matching, key=_platform_component_score)
|
|
147
|
+
raise ValueError(f"Версия {version} не найдена среди установленных платформ 1С")
|
|
148
|
+
# Numeric compare using the first three components to avoid treating the
|
|
149
|
+
# build number as the most significant part. Windows candidates usually
|
|
150
|
+
# point to .../<version>/bin, so the version is in the parent directory.
|
|
151
|
+
return max(
|
|
152
|
+
candidates,
|
|
153
|
+
key=lambda p: (_version_tuple(platform_version_from_dir(p)), _platform_component_score(p)),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def platform_version_from_dir(platform_dir: Path) -> str:
|
|
158
|
+
"""Return the 1C version represented by a platform directory.
|
|
159
|
+
|
|
160
|
+
Linux installs typically use /opt/1cv8/x86_64/<version>/1cv8c, while
|
|
161
|
+
Windows installs use C:\\Program Files\\1cv8\\<version>\\bin\\1cv8c.exe.
|
|
162
|
+
For Windows bin directories, the version is therefore the parent name.
|
|
163
|
+
"""
|
|
164
|
+
path = Path(platform_dir)
|
|
165
|
+
if path.name.lower() == "bin" and path.parent.name:
|
|
166
|
+
return path.parent.name
|
|
167
|
+
return path.name
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _version_tuple(version: str) -> tuple[int, int, int, int]:
|
|
171
|
+
parts = version.split(".")
|
|
172
|
+
nums: list[int] = []
|
|
173
|
+
for p in parts[:4]:
|
|
174
|
+
try:
|
|
175
|
+
nums.append(int(p))
|
|
176
|
+
except ValueError:
|
|
177
|
+
nums.append(0)
|
|
178
|
+
while len(nums) < 4:
|
|
179
|
+
nums.append(0)
|
|
180
|
+
return (nums[0], nums[1], nums[2], nums[3])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def require_websocket_support(version: str) -> None:
|
|
184
|
+
"""Answer42 requires 1С:Предприятие 8.3.27+."""
|
|
185
|
+
major, minor, patch, _ = _version_tuple(version)
|
|
186
|
+
if (major, minor, patch) < (8, 3, 27):
|
|
187
|
+
raise ValueError(f"Версия {version} не поддерживается. Требуется 1С:Предприятие 8.3.27+.")
|
mcp_1c/protocol.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class BridgeRequest:
|
|
9
|
+
"""Command sent from the Python MCP side to the 1C bridge over WebSocket."""
|
|
10
|
+
|
|
11
|
+
id: str
|
|
12
|
+
method: str
|
|
13
|
+
params: dict[str, Any]
|
|
14
|
+
|
|
15
|
+
def to_json(self) -> dict[str, Any]:
|
|
16
|
+
return {"type": "request", "id": self.id, "method": self.method, "params": self.params}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class BridgeResponse:
|
|
21
|
+
"""Response returned by the 1C bridge."""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
ok: bool
|
|
25
|
+
result: Any = None
|
|
26
|
+
error: str | None = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_json(cls, data: dict[str, Any]) -> "BridgeResponse":
|
|
30
|
+
return cls(
|
|
31
|
+
id=str(data.get("id", "")),
|
|
32
|
+
ok=bool(data.get("ok", False)),
|
|
33
|
+
result=data.get("result"),
|
|
34
|
+
error=data.get("error"),
|
|
35
|
+
)
|
mcp_1c/rag/__init__.py
ADDED
mcp_1c/rag/detect.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .model import SourceFormat
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def detect_source_format(path: str | Path) -> SourceFormat:
|
|
9
|
+
root = Path(path)
|
|
10
|
+
if not root.exists() or not root.is_dir():
|
|
11
|
+
return "unknown"
|
|
12
|
+
|
|
13
|
+
# EDT-style project: Configuration/Configuration.mdo and object folders with *.mdo.
|
|
14
|
+
if (root / "Configuration" / "Configuration.mdo").exists():
|
|
15
|
+
return "edt"
|
|
16
|
+
if any(root.glob("Catalogs/*/*.mdo")) or any(root.glob("Documents/*/*.mdo")) or any(root.glob("DataProcessors/*/*.mdo")):
|
|
17
|
+
return "edt"
|
|
18
|
+
|
|
19
|
+
# Designer XML dump: Configuration.xml, object XML at root folders, Ext/Form.xml.
|
|
20
|
+
if (root / "Configuration.xml").exists() or any(root.glob("*/**/Ext/Form.xml")) or any(root.glob("Catalogs/*.xml")):
|
|
21
|
+
return "designer_xml"
|
|
22
|
+
|
|
23
|
+
return "unknown"
|
mcp_1c/rag/model.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
SourceFormat = Literal["edt", "designer_xml", "unknown"]
|
|
8
|
+
SourceKind = Literal["base", "extension", "configuration", "standalone"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class SourceInfo:
|
|
13
|
+
name: str
|
|
14
|
+
path: Path
|
|
15
|
+
kind: SourceKind = "standalone"
|
|
16
|
+
format: SourceFormat = "unknown"
|
|
17
|
+
repo_url: str | None = None
|
|
18
|
+
branch: str | None = None
|
|
19
|
+
commit: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class MetadataObject:
|
|
24
|
+
full_name: str
|
|
25
|
+
kind: str
|
|
26
|
+
name: str
|
|
27
|
+
synonym: str | None = None
|
|
28
|
+
source_path: str | None = None
|
|
29
|
+
source_format: SourceFormat = "unknown"
|
|
30
|
+
is_hierarchical: bool | None = None
|
|
31
|
+
owner_types: list[str] = field(default_factory=list)
|
|
32
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class Attribute:
|
|
37
|
+
object_full_name: str
|
|
38
|
+
name: str
|
|
39
|
+
type_name: str | None = None
|
|
40
|
+
synonym: str | None = None
|
|
41
|
+
required: bool | None = None
|
|
42
|
+
source_path: str | None = None
|
|
43
|
+
role: str = "attribute"
|
|
44
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class TablePart:
|
|
49
|
+
object_full_name: str
|
|
50
|
+
name: str
|
|
51
|
+
synonym: str | None = None
|
|
52
|
+
source_path: str | None = None
|
|
53
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class TablePartColumn:
|
|
58
|
+
object_full_name: str
|
|
59
|
+
table_part: str
|
|
60
|
+
name: str
|
|
61
|
+
type_name: str | None = None
|
|
62
|
+
synonym: str | None = None
|
|
63
|
+
required: bool | None = None
|
|
64
|
+
source_path: str | None = None
|
|
65
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class Form:
|
|
70
|
+
object_full_name: str
|
|
71
|
+
name: str
|
|
72
|
+
kind: str | None = None
|
|
73
|
+
source_path: str | None = None
|
|
74
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class FormElement:
|
|
79
|
+
object_full_name: str
|
|
80
|
+
form_name: str
|
|
81
|
+
name: str
|
|
82
|
+
element_type: str | None = None
|
|
83
|
+
title: str | None = None
|
|
84
|
+
data_path: str | None = None
|
|
85
|
+
command_name: str | None = None
|
|
86
|
+
parent_name: str | None = None
|
|
87
|
+
source_path: str | None = None
|
|
88
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class DataCompositionField:
|
|
93
|
+
object_full_name: str
|
|
94
|
+
schema_name: str
|
|
95
|
+
data_path: str
|
|
96
|
+
source_field: str | None = None
|
|
97
|
+
title: str | None = None
|
|
98
|
+
expression: str | None = None
|
|
99
|
+
dataset_name: str | None = None
|
|
100
|
+
field_type: str | None = None
|
|
101
|
+
source_path: str | None = None
|
|
102
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(slots=True)
|
|
106
|
+
class ParsedSource:
|
|
107
|
+
source: SourceInfo
|
|
108
|
+
objects: list[MetadataObject] = field(default_factory=list)
|
|
109
|
+
attributes: list[Attribute] = field(default_factory=list)
|
|
110
|
+
table_parts: list[TablePart] = field(default_factory=list)
|
|
111
|
+
table_part_columns: list[TablePartColumn] = field(default_factory=list)
|
|
112
|
+
forms: list[Form] = field(default_factory=list)
|
|
113
|
+
form_elements: list[FormElement] = field(default_factory=list)
|
|
114
|
+
data_composition_fields: list[DataCompositionField] = field(default_factory=list)
|