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/recorder.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class RecordingSession:
|
|
16
|
+
active: bool = False
|
|
17
|
+
output_dir: Path = Path("build/recordings/session")
|
|
18
|
+
display: str = ""
|
|
19
|
+
window: bool = False
|
|
20
|
+
frames_dir: Path = Path("build/recordings/session/frames")
|
|
21
|
+
slides_dir: Path = Path("build/recordings/session/slides")
|
|
22
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def start(self, output_dir: str, display: str = "", window: bool = False) -> dict[str, Any]:
|
|
25
|
+
self.active = True
|
|
26
|
+
self.output_dir = Path(output_dir)
|
|
27
|
+
self.display = display
|
|
28
|
+
self.window = window
|
|
29
|
+
self.frames_dir = self.output_dir / "frames"
|
|
30
|
+
self.slides_dir = self.output_dir / "slides"
|
|
31
|
+
self.events = []
|
|
32
|
+
self.frames_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
self.slides_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
# MP4/GIF generation is intentionally unsupported. Remove stale files
|
|
35
|
+
# from older runs so evidence directories reflect current outputs only.
|
|
36
|
+
for obsolete in ("recording.mp4", "recording.gif"):
|
|
37
|
+
try:
|
|
38
|
+
(self.output_dir / obsolete).unlink()
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
pass
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
return {"active": True, "output_dir": str(self.output_dir)}
|
|
44
|
+
|
|
45
|
+
def stop(self) -> dict[str, Any]:
|
|
46
|
+
self.active = False
|
|
47
|
+
manifest = self.output_dir / "manifest.json"
|
|
48
|
+
manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
manifest.write_text(json.dumps({"events": self.events}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
50
|
+
media = self.build_media()
|
|
51
|
+
return {"active": False, "output_dir": str(self.output_dir), "events": len(self.events), **media}
|
|
52
|
+
|
|
53
|
+
def capture(self, screenshot_func, action: str, params: dict[str, Any], result: Any, *, screenshot: bool = True) -> dict[str, Any] | None:
|
|
54
|
+
if not self.active:
|
|
55
|
+
return None
|
|
56
|
+
index = len(self.events) + 1
|
|
57
|
+
slide = self.slides_dir / f"{index:03d}_{safe_name(action)}.svg"
|
|
58
|
+
safe_params = redact(params)
|
|
59
|
+
safe_result = redact(result)
|
|
60
|
+
frame = self.frames_dir / f"{index:03d}_{safe_name(action)}.png"
|
|
61
|
+
if screenshot:
|
|
62
|
+
try:
|
|
63
|
+
screenshot_func(str(frame), self.window, self.display)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
event = {"index": index, "action": action, "params": params, "result": summarize(result), "screenshot_error": str(exc)}
|
|
66
|
+
self.events.append(event)
|
|
67
|
+
return event
|
|
68
|
+
slide.write_text(make_slide_svg(frame, action, safe_params, safe_result, index), encoding="utf-8")
|
|
69
|
+
else:
|
|
70
|
+
slide.write_text(make_text_slide_svg(action, safe_params, safe_result, index), encoding="utf-8")
|
|
71
|
+
event = {
|
|
72
|
+
"index": index,
|
|
73
|
+
"action": action,
|
|
74
|
+
"params": safe_params,
|
|
75
|
+
"result": summarize(safe_result),
|
|
76
|
+
"slide": str(slide),
|
|
77
|
+
}
|
|
78
|
+
if screenshot:
|
|
79
|
+
event["frame"] = str(frame)
|
|
80
|
+
else:
|
|
81
|
+
event["text_only"] = True
|
|
82
|
+
self.events.append(event)
|
|
83
|
+
return event
|
|
84
|
+
|
|
85
|
+
def build_media(self) -> dict[str, Any]:
|
|
86
|
+
slides = sorted(self.slides_dir.glob("*.svg"))
|
|
87
|
+
if not slides:
|
|
88
|
+
return {"pdf": ""}
|
|
89
|
+
pdf = self.output_dir / "recording.pdf"
|
|
90
|
+
html_deck = self.output_dir / "recording.html"
|
|
91
|
+
html_deck.write_text(make_html_deck(slides), encoding="utf-8")
|
|
92
|
+
browser = first_existing(["google-chrome", "chromium", "chromium-browser", "chrome.exe", "msedge.exe"])
|
|
93
|
+
if browser:
|
|
94
|
+
run([
|
|
95
|
+
browser,
|
|
96
|
+
"--headless=new",
|
|
97
|
+
"--no-sandbox",
|
|
98
|
+
"--disable-gpu",
|
|
99
|
+
f"--print-to-pdf={pdf.resolve()}",
|
|
100
|
+
str(html_deck.resolve()),
|
|
101
|
+
], timeout=240)
|
|
102
|
+
if not pdf.exists():
|
|
103
|
+
converter_names = ["magick", "magick.exe"] if os.name == "nt" else ["magick", "convert"]
|
|
104
|
+
converter = first_existing(converter_names)
|
|
105
|
+
if converter:
|
|
106
|
+
run([converter, *map(str, slides), str(pdf)], timeout=240)
|
|
107
|
+
if html_deck.exists():
|
|
108
|
+
try:
|
|
109
|
+
html_deck.unlink()
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
return {"pdf": str(pdf) if pdf.exists() else ""}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def make_html_deck(slides: list[Path]) -> str:
|
|
116
|
+
pages = []
|
|
117
|
+
for slide in slides:
|
|
118
|
+
data = slide.read_text(encoding="utf-8")
|
|
119
|
+
pages.append(f'<section class="page">{data}</section>')
|
|
120
|
+
return '''<!doctype html>
|
|
121
|
+
<html><head><meta charset="utf-8"><style>
|
|
122
|
+
@page { size: 16in 9in; margin: 0; }
|
|
123
|
+
html, body { margin: 0; padding: 0; background: #111827; }
|
|
124
|
+
.page { width: 16in; height: 9in; page-break-after: always; overflow: hidden; }
|
|
125
|
+
.page svg { width: 16in; height: 9in; display: block; }
|
|
126
|
+
</style></head><body>''' + "\n".join(pages) + "</body></html>"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run(args: list[str], timeout: int = 180) -> None:
|
|
130
|
+
try:
|
|
131
|
+
subprocess.run(args, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def first_existing(names: list[str]) -> str:
|
|
137
|
+
from shutil import which
|
|
138
|
+
for name in names:
|
|
139
|
+
path = which(name)
|
|
140
|
+
if path:
|
|
141
|
+
return path
|
|
142
|
+
if os.name == "nt":
|
|
143
|
+
env_paths = [
|
|
144
|
+
os.getenv("ProgramFiles"),
|
|
145
|
+
os.getenv("ProgramFiles(x86)"),
|
|
146
|
+
os.getenv("LocalAppData"),
|
|
147
|
+
]
|
|
148
|
+
candidates = []
|
|
149
|
+
for root in filter(None, env_paths):
|
|
150
|
+
base = Path(str(root))
|
|
151
|
+
candidates.extend([
|
|
152
|
+
base / "Google/Chrome/Application/chrome.exe",
|
|
153
|
+
base / "Microsoft/Edge/Application/msedge.exe",
|
|
154
|
+
base / "Programs/Microsoft VS Code/chrome.exe",
|
|
155
|
+
])
|
|
156
|
+
for candidate in candidates:
|
|
157
|
+
if candidate.exists():
|
|
158
|
+
return str(candidate)
|
|
159
|
+
return ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
SENSITIVE_KEYS = {"password", "pwd", "pass", "secret", "token", "authorization", "auth"}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def redact(value: Any) -> Any:
|
|
166
|
+
if isinstance(value, dict):
|
|
167
|
+
result = {}
|
|
168
|
+
for key, item in value.items():
|
|
169
|
+
if str(key).lower() in SENSITIVE_KEYS or "password" in str(key).lower():
|
|
170
|
+
result[key] = "***"
|
|
171
|
+
else:
|
|
172
|
+
result[key] = redact(item)
|
|
173
|
+
return result
|
|
174
|
+
if isinstance(value, list):
|
|
175
|
+
return [redact(item) for item in value]
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def safe_name(value: str) -> str:
|
|
180
|
+
result = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in value)[:80]
|
|
181
|
+
return result or "action"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def summarize(value: Any, limit: int = 900) -> str:
|
|
185
|
+
try:
|
|
186
|
+
text = json.dumps(value, ensure_ascii=False, default=str)
|
|
187
|
+
except Exception:
|
|
188
|
+
text = str(value)
|
|
189
|
+
return text if len(text) <= limit else text[:limit] + "…"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def make_slide_svg(frame: Path, action: str, params: dict[str, Any], result: Any, index: int) -> str:
|
|
193
|
+
data = base64.b64encode(frame.read_bytes()).decode("ascii")
|
|
194
|
+
param_text = summarize(params, 700)
|
|
195
|
+
result_text = summarize(result, 900)
|
|
196
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
197
|
+
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
|
|
198
|
+
<rect width="1920" height="1080" fill="#111827"/>
|
|
199
|
+
<rect x="24" y="24" width="1344" height="1032" rx="18" fill="#0b1220"/>
|
|
200
|
+
<image x="48" y="48" width="1296" height="972" href="data:image/png;base64,{data}" preserveAspectRatio="xMidYMid meet"/>
|
|
201
|
+
<rect x="1392" y="24" width="504" height="1032" rx="18" fill="#f8fafc"/>
|
|
202
|
+
<text x="1424" y="78" font-family="Arial" font-size="28" font-weight="700" fill="#111827">#{index} {html.escape(action)}</text>
|
|
203
|
+
<text x="1424" y="116" font-family="Arial" font-size="16" fill="#64748b">{html.escape(now)}</text>
|
|
204
|
+
<text x="1424" y="170" font-family="Arial" font-size="22" font-weight="700" fill="#0f172a">Параметры</text>
|
|
205
|
+
{multiline_text(param_text, 1424, 204, 440, 17, '#334155')}
|
|
206
|
+
<text x="1424" y="550" font-family="Arial" font-size="22" font-weight="700" fill="#0f172a">Результат</text>
|
|
207
|
+
{multiline_text(result_text, 1424, 584, 440, 17, '#334155')}
|
|
208
|
+
</svg>'''
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def make_text_slide_svg(action: str, params: dict[str, Any], result: Any, index: int) -> str:
|
|
212
|
+
param_text = summarize(params, 1800)
|
|
213
|
+
result_text = summarize(result, 3000)
|
|
214
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
215
|
+
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
|
|
216
|
+
<rect width="1920" height="1080" fill="#111827"/>
|
|
217
|
+
<rect x="48" y="40" width="1824" height="1000" rx="18" fill="#f8fafc"/>
|
|
218
|
+
<text x="88" y="96" font-family="Arial" font-size="32" font-weight="700" fill="#111827">#{index} {html.escape(action)}</text>
|
|
219
|
+
<text x="88" y="134" font-family="Arial" font-size="16" fill="#64748b">{html.escape(now)} · text-only MCP evidence (no UI screenshot)</text>
|
|
220
|
+
<text x="88" y="196" font-family="Arial" font-size="24" font-weight="700" fill="#0f172a">Параметры</text>
|
|
221
|
+
{multiline_text(param_text, 88, 232, 1720, 18, '#334155', max_lines=14)}
|
|
222
|
+
<text x="88" y="552" font-family="Arial" font-size="24" font-weight="700" fill="#0f172a">Результат</text>
|
|
223
|
+
{multiline_text(result_text, 88, 588, 1720, 18, '#334155', max_lines=20)}
|
|
224
|
+
</svg>'''
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def multiline_text(text: str, x: int, y: int, width: int, font_size: int, color: str, max_lines: int = 22) -> str:
|
|
228
|
+
chars = max(20, width // int(font_size * 0.55))
|
|
229
|
+
lines: list[str] = []
|
|
230
|
+
for raw in text.splitlines() or [""]:
|
|
231
|
+
s = raw
|
|
232
|
+
while len(s) > chars:
|
|
233
|
+
lines.append(s[:chars])
|
|
234
|
+
s = s[chars:]
|
|
235
|
+
lines.append(s)
|
|
236
|
+
out = []
|
|
237
|
+
for i, line in enumerate(lines[:max_lines]):
|
|
238
|
+
out.append(f'<text x="{x}" y="{y + i * (font_size + 5)}" font-family="Arial" font-size="{font_size}" fill="{color}">{html.escape(line)}</text>')
|
|
239
|
+
return "\n ".join(out)
|
mcp_1c/release_helper.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Release helper for Answer42 source checkouts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[A-Za-z0-9_.+-]*)?$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run(cmd: list[str], *, dry_run: bool) -> None:
|
|
14
|
+
print("+", " ".join(cmd))
|
|
15
|
+
if not dry_run:
|
|
16
|
+
subprocess.run(cmd, check=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _git_output(args: list[str]) -> str:
|
|
20
|
+
return subprocess.check_output(["git", *args], text=True).strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _replace_version(pyproject: Path, version: str) -> bool:
|
|
24
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
25
|
+
pattern = r'(?m)^version\s*=\s*"([^"]+)"\s*$'
|
|
26
|
+
match = re.search(pattern, text)
|
|
27
|
+
if not match:
|
|
28
|
+
raise RuntimeError(f"Could not find project.version in {pyproject}")
|
|
29
|
+
if match.group(1) == version:
|
|
30
|
+
return False
|
|
31
|
+
new_text, count = re.subn(pattern, f'version = "{version}"', text, count=1)
|
|
32
|
+
if count != 1:
|
|
33
|
+
raise RuntimeError(f"Could not replace project.version in {pyproject}")
|
|
34
|
+
pyproject.write_text(new_text, encoding="utf-8")
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> int:
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
description="Bump pyproject.toml version, commit, tag and push to trigger PyPI release CI"
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("version", help="Version without leading v, e.g. 0.2.1")
|
|
43
|
+
parser.add_argument("--remote", default="origin", help="Git remote to push. Default: origin")
|
|
44
|
+
parser.add_argument("--branch", default="HEAD", help="Branch/ref to push. Default: HEAD")
|
|
45
|
+
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing them")
|
|
46
|
+
parser.add_argument("--no-push", action="store_true", help="Create commit/tag locally but do not push")
|
|
47
|
+
parser.add_argument("--allow-dirty", action="store_true", help="Allow unrelated uncommitted changes")
|
|
48
|
+
args = parser.parse_args(argv)
|
|
49
|
+
|
|
50
|
+
version = args.version.removeprefix("v")
|
|
51
|
+
if not SEMVER_RE.match(version):
|
|
52
|
+
raise SystemExit(f"Invalid version {args.version!r}; expected e.g. 0.2.1")
|
|
53
|
+
tag = f"v{version}"
|
|
54
|
+
|
|
55
|
+
repo_root = Path(_git_output(["rev-parse", "--show-toplevel"]))
|
|
56
|
+
pyproject = repo_root / "pyproject.toml"
|
|
57
|
+
status = _git_output(["status", "--porcelain"])
|
|
58
|
+
if status and not args.allow_dirty:
|
|
59
|
+
raise SystemExit(
|
|
60
|
+
"Working tree is not clean. Commit/stash changes first, or pass --allow-dirty.\n" + status
|
|
61
|
+
)
|
|
62
|
+
existing_tags = _git_output(["tag", "--list", tag])
|
|
63
|
+
if existing_tags:
|
|
64
|
+
raise SystemExit(f"Tag already exists: {tag}")
|
|
65
|
+
|
|
66
|
+
print(f"Bumping {pyproject} to {version}")
|
|
67
|
+
changed = True
|
|
68
|
+
if not args.dry_run:
|
|
69
|
+
changed = _replace_version(pyproject, version)
|
|
70
|
+
else:
|
|
71
|
+
print(f"+ update {pyproject}: version = {version!r}")
|
|
72
|
+
|
|
73
|
+
if changed:
|
|
74
|
+
_run(["git", "add", str(pyproject.relative_to(repo_root))], dry_run=args.dry_run)
|
|
75
|
+
_run(["git", "commit", "-m", f"Release {tag}"], dry_run=args.dry_run)
|
|
76
|
+
else:
|
|
77
|
+
print(f"Version is already {version}; tagging current HEAD without a version commit.")
|
|
78
|
+
_run(["git", "tag", "-a", tag, "-m", f"Release {tag}"], dry_run=args.dry_run)
|
|
79
|
+
if not args.no_push:
|
|
80
|
+
_run(["git", "push", args.remote, args.branch], dry_run=args.dry_run)
|
|
81
|
+
_run(["git", "push", args.remote, tag], dry_run=args.dry_run)
|
|
82
|
+
print("CI will publish PyPI package and create GitLab Release from the pushed tag.")
|
|
83
|
+
return 0
|