nedviga-cli 0.1.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.
Files changed (71) hide show
  1. nedviga/__init__.py +46 -0
  2. nedviga/__main__.py +6 -0
  3. nedviga/_wm.py +93 -0
  4. nedviga/adaptive_selectors.py +100 -0
  5. nedviga/auditlog.py +77 -0
  6. nedviga/auto.py +88 -0
  7. nedviga/bargain.py +183 -0
  8. nedviga/browser_native.py +242 -0
  9. nedviga/callsheet.py +63 -0
  10. nedviga/chains.py +83 -0
  11. nedviga/checklist.py +119 -0
  12. nedviga/cian_card.py +1371 -0
  13. nedviga/cian_live.py +812 -0
  14. nedviga/cli.py +3118 -0
  15. nedviga/compare.py +204 -0
  16. nedviga/data/legal_flags.json +146 -0
  17. nedviga/data/release_pubkey.asc +22 -0
  18. nedviga/deck.py +115 -0
  19. nedviga/dedup.py +242 -0
  20. nedviga/developer.py +169 -0
  21. nedviga/digest.py +205 -0
  22. nedviga/doctor.py +128 -0
  23. nedviga/dossier.py +303 -0
  24. nedviga/eco.py +81 -0
  25. nedviga/errors.py +22 -0
  26. nedviga/export_html.py +280 -0
  27. nedviga/export_pdf.py +96 -0
  28. nedviga/family_card.py +670 -0
  29. nedviga/feedback.py +79 -0
  30. nedviga/geo_routing.py +214 -0
  31. nedviga/graph.py +149 -0
  32. nedviga/legal_triage.py +105 -0
  33. nedviga/litprecheck.py +116 -0
  34. nedviga/market.py +102 -0
  35. nedviga/mcp_server.py +195 -0
  36. nedviga/metro_directory.json +9836 -0
  37. nedviga/metro_vestibules.py +134 -0
  38. nedviga/models.py +164 -0
  39. nedviga/mosdata.py +95 -0
  40. nedviga/net_guard.py +997 -0
  41. nedviga/noise.py +156 -0
  42. nedviga/ops.py +94 -0
  43. nedviga/outcomes.py +35 -0
  44. nedviga/pii.py +85 -0
  45. nedviga/pipeline.py +239 -0
  46. nedviga/poi.py +180 -0
  47. nedviga/price_guard.py +73 -0
  48. nedviga/profiles.py +131 -0
  49. nedviga/projects.py +112 -0
  50. nedviga/purge.py +116 -0
  51. nedviga/py.typed +0 -0
  52. nedviga/risk.py +127 -0
  53. nedviga/roles.py +124 -0
  54. nedviga/routing_cache.py +72 -0
  55. nedviga/rush.py +139 -0
  56. nedviga/scorefit.py +127 -0
  57. nedviga/sources.py +55 -0
  58. nedviga/sources_avito.py +138 -0
  59. nedviga/sources_domclick.py +122 -0
  60. nedviga/stale.py +83 -0
  61. nedviga/textsearch.py +141 -0
  62. nedviga/tour.py +180 -0
  63. nedviga/watch.py +234 -0
  64. nedviga/wizard.py +106 -0
  65. nedviga_cli-0.1.0.dist-info/METADATA +130 -0
  66. nedviga_cli-0.1.0.dist-info/RECORD +71 -0
  67. nedviga_cli-0.1.0.dist-info/WHEEL +5 -0
  68. nedviga_cli-0.1.0.dist-info/entry_points.txt +2 -0
  69. nedviga_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
  70. nedviga_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
  71. nedviga_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,242 @@
1
+ """Native embedded Camoufox browser for nedviga (no external HTTP service).
2
+
3
+ Replaces any external browser service with an in-process browser launched via
4
+ the ``camoufox`` Python library. Fail-closed: every failure surfaces as
5
+ :class:`FetchError` with an actionable message — never a fabricated result.
6
+
7
+ Design:
8
+ - Lazy singleton: the browser process starts on the first ``fetch()`` call and
9
+ stays alive for the process lifetime (per CLI invocation, per API process).
10
+ - Bounded crash recovery: if the browser dies mid-flight ("window is null",
11
+ protocol errors), the next ``fetch()`` relaunches it once.
12
+ - GTK workaround: system ``libgtk-3.so.0`` may be absent; the bundled libs in
13
+ ``~/.local/camofox-libs`` are injected via LD_LIBRARY_PATH before Playwright
14
+ launches. Configurable through ``CW_NEDVIGA_LD_PATH``.
15
+ - Thread-safety: guarded by locks; the sync Playwright API is used, one page at
16
+ a time.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Any, Callable
26
+
27
+ from .errors import FetchError
28
+
29
+ NativeFetcher = Callable[[str], str]
30
+
31
+ _native_tls = threading.local()
32
+
33
+ _LAUNCH_LOCK = threading.Lock()
34
+ _FETCH_LOCK = threading.Lock()
35
+ _BROWSER_HOLDER: dict[str, Any] = {"browser": None, "pw": None}
36
+ _RELAUNCH_TS = 0.0
37
+ _RELAUNCH_MIN_INTERVAL = 5.0
38
+
39
+ # Locations of bundled GTK libs (same set the external camofox-browser service
40
+ # uses), checked in order. Only the first existing path is injected.
41
+ _CANDIDATE_LD_PATHS = (
42
+ "~/.local/camofox-libs/usr/lib/x86_64-linux-gnu",
43
+ "~/.local/camofox-libs/usr/lib/x86_64-linux-gnu:"
44
+ "~/.local/camofox-libs/lib/x86_64-linux-gnu",
45
+ )
46
+
47
+
48
+ def _library_path() -> str | None:
49
+ """Resolve LD_LIBRARY_PATH addition for the embedded browser."""
50
+ override = os.environ.get("CW_NEDVIGA_LD_PATH")
51
+ if override:
52
+ return override
53
+ for candidate in _CANDIDATE_LD_PATHS:
54
+ parts = [os.path.expanduser(p) for p in candidate.split(":")]
55
+ if all(os.path.exists(p) for p in parts):
56
+ return ":".join(parts)
57
+ return None
58
+
59
+
60
+ def _install_cache_env() -> None:
61
+ """Isolate the python-camoufox browser cache from any other service cache.
62
+
63
+ The external camofox-browser service (node) and the python camoufox package
64
+ share ``~/.cache/camoufox`` by default but expect DIFFERENT layouts; a
65
+ python ``camoufox fetch`` wipes the node-side metadata. Point python at its
66
+ own XDG cache dir (its INSTALL_DIR = $XDG_CACHE_HOME/camoufox).
67
+ """
68
+ cache = os.environ.get(
69
+ "CW_NEDVIGA_CACHE", str(Path.home() / ".cache" / "nedviga-py")
70
+ )
71
+ os.environ.setdefault("XDG_CACHE_HOME", cache)
72
+
73
+
74
+ def ensure_launch_env() -> None:
75
+ """Inject bundled GTK libs into LD_LIBRARY_PATH.
76
+
77
+ Must run BEFORE Playwright spawns the browser process: the driver
78
+ propagates the current process environment at spawn time, so setting it
79
+ later has no effect. Called at module import (idempotent).
80
+ """
81
+ ld = _library_path()
82
+ if not ld:
83
+ return
84
+ current = os.environ.get("LD_LIBRARY_PATH", "")
85
+ if ld in current:
86
+ return
87
+ os.environ["LD_LIBRARY_PATH"] = f"{ld}:{current}" if current else ld
88
+
89
+
90
+ def native_available() -> bool:
91
+ """True when the camoufox python package can be imported."""
92
+ try:
93
+ import camoufox # noqa: F401
94
+
95
+ return True
96
+ except Exception:
97
+ return False
98
+
99
+
100
+ def _launch_browser() -> Any:
101
+ """Launch the embedded Camoufox browser; return (playwright, browser)."""
102
+ try:
103
+ from camoufox.sync_api import Camoufox
104
+ except Exception as exc: # pragma: no cover - import-time
105
+ raise FetchError(
106
+ f"embedded camoufox not importable: {exc}. "
107
+ "Install it with: pip install 'camoufox[geoip]' && "
108
+ "python3 -m camoufox fetch"
109
+ ) from exc
110
+
111
+ try:
112
+ profile_dir = Path.home() / ".cache" / "nedviga-cli" / "native-profile"
113
+ profile_dir.mkdir(parents=True, exist_ok=True)
114
+ pw_browser = Camoufox(
115
+ headless=True,
116
+ humanize=True,
117
+ os="linux",
118
+ persistent_context=True,
119
+ user_data_dir=str(profile_dir),
120
+ enable_cache=True,
121
+ )
122
+ browser = pw_browser.__enter__()
123
+ return pw_browser, browser
124
+ except Exception as exc:
125
+ raise FetchError(
126
+ f"embedded camoufox launch failed: {exc}. "
127
+ "Check camoufox installation and GTK libs "
128
+ "(CW_NEDVIGA_LD_PATH may point at bundled libs)."
129
+ ) from exc
130
+
131
+
132
+ def _get_browser() -> Any:
133
+ """Return the live browser, (re)launching if needed."""
134
+ global _RELAUNCH_TS
135
+ with _LAUNCH_LOCK:
136
+ browser = _BROWSER_HOLDER.get("browser")
137
+ if browser is not None:
138
+ try:
139
+ # Cheap liveness probe: an attached context object.
140
+ _ = browser.browser_type # attribute touch
141
+ return browser
142
+ except Exception:
143
+ # Browser died; fall through to relaunch.
144
+ pass
145
+ now = time.monotonic()
146
+ if (
147
+ now - _RELAUNCH_TS < _RELAUNCH_MIN_INTERVAL
148
+ and _BROWSER_HOLDER.get("browser") is None
149
+ ):
150
+ raise FetchError(
151
+ "embedded camoufox relaunch cooldown active (recent crash). "
152
+ "Retry in a few seconds."
153
+ )
154
+ _RELAUNCH_TS = now
155
+ _install_cache_env()
156
+ ensure_launch_env()
157
+ pw, browser = _launch_browser()
158
+ _BROWSER_HOLDER["pw"] = pw
159
+ _BROWSER_HOLDER["browser"] = browser
160
+ return browser
161
+
162
+
163
+ def _close_browser() -> None:
164
+ with _LAUNCH_LOCK:
165
+ browser = _BROWSER_HOLDER.get("browser")
166
+ pw = _BROWSER_HOLDER.get("pw")
167
+ try:
168
+ if pw is not None:
169
+ pw.__exit__(None, None, None)
170
+ except Exception:
171
+ pass
172
+ _BROWSER_HOLDER["browser"] = None
173
+ _BROWSER_HOLDER["pw"] = None
174
+
175
+
176
+ def get_last_http_status() -> str | None:
177
+ """Return the HTTP status code (as string) from the most recent ``native_fetch``.
178
+
179
+ Returns ``None`` if no fetch has been performed or if the status is unavailable.
180
+ Thread-local: each thread sees only its own last status.
181
+ """
182
+ status = getattr(_native_tls, "http_status", None)
183
+ return str(status) if status is not None else None
184
+
185
+
186
+ def native_fetch(
187
+ url: str, *, timeout: float = 60.0, settle_ms: int = 1500
188
+ ) -> str:
189
+ """Render ``url`` through the embedded Camoufox and return outerHTML.
190
+
191
+ Fail-closed: any launch/navigation/evaluate failure raises :class:`FetchError`
192
+ with an actionable message.
193
+
194
+ After each call (success or failure) the HTTP status code is available via
195
+ :func:`get_last_http_status` for challenge detection.
196
+ """
197
+ with _FETCH_LOCK:
198
+ _native_tls.http_status = None
199
+ browser = _get_browser()
200
+ page = None
201
+ try:
202
+ try:
203
+ page = browser.new_page()
204
+ except Exception as exc:
205
+ # Browser process died between calls: one relaunch attempt.
206
+ _close_browser()
207
+ browser = _get_browser()
208
+ page = browser.new_page()
209
+ response = page.goto(url, timeout=int(timeout * 1000))
210
+ if response is not None:
211
+ _native_tls.http_status = response.status
212
+ # Brief settle for JS-rendered content.
213
+ time.sleep(settle_ms / 1000.0)
214
+ html = page.evaluate("document.documentElement.outerHTML")
215
+ if not isinstance(html, str) or not html.strip():
216
+ raise FetchError(f"embedded camoufox: empty DOM for {url}")
217
+ return html
218
+ except FetchError:
219
+ raise
220
+ except Exception as exc:
221
+ # Mark the browser dead so the next call relaunches cleanly.
222
+ _close_browser()
223
+ raise FetchError(
224
+ f"embedded camoufox fetch failed for {url}: {exc}. "
225
+ "The browser will relaunch on the next call."
226
+ ) from exc
227
+ finally:
228
+ if page is not None:
229
+ try:
230
+ page.close()
231
+ except Exception:
232
+ pass
233
+
234
+
235
+ def native_status() -> dict[str, Any]:
236
+ """Machine-readable status of the embedded browser (for doctor)."""
237
+ browser = _BROWSER_HOLDER.get("browser")
238
+ return {
239
+ "mode": "native",
240
+ "available": native_available(),
241
+ "running": browser is not None,
242
+ }
nedviga/callsheet.py ADDED
@@ -0,0 +1,63 @@
1
+ """v0.5.0 (the task brief) call-sheet."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from .legal_triage import triage
9
+ from .models import ListingCard
10
+ from .pii import PHONE_MASK
11
+
12
+
13
+ def _listing(card: Any) -> ListingCard:
14
+ if isinstance(card, ListingCard):
15
+ return card
16
+ if isinstance(card, dict):
17
+ return ListingCard(**card)
18
+ raise TypeError("unsupported card")
19
+
20
+
21
+ def _load_cards(path: str) -> list[ListingCard]:
22
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
23
+ if isinstance(raw, dict):
24
+ if "cards" in raw and isinstance(raw["cards"], list):
25
+ raw = raw["cards"]
26
+ elif "data" in raw:
27
+ d = raw["data"]
28
+ raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
29
+ else:
30
+ raw = [raw]
31
+ if not isinstance(raw, list):
32
+ raw = [raw]
33
+ return [_listing(x) for x in raw if isinstance(x, (dict, ListingCard))]
34
+
35
+
36
+ def build_callsheet(cards: list[ListingCard], with_contacts: bool = False) -> str:
37
+ lines: list[str] = []
38
+ for c in cards:
39
+ t = triage(c)
40
+ phones = [str(p) for p in (c.seller.phones if c.seller else []) if str(p or "").strip()]
41
+ if with_contacts:
42
+ phone_str = " · ".join(phones) if phones else "нет"
43
+ else:
44
+ phone_str = " · ".join(PHONE_MASK for _ in phones) if phones else "нет"
45
+ lines.append(f"📞 {c.title or c.id}")
46
+ lines.append(f"Телефон: {phone_str}")
47
+ lines.append(f"Цена: {c.pricing.price_total_rub} ₽ ({c.pricing.price_per_m2_rub} ₽/м²)")
48
+ lines.append(f"Район: {c.geo.district or 'нет данных'}")
49
+ lines.append("Вопросы:")
50
+ lines.append("• Почему цена отличается от медианы группы? (если применимо)")
51
+ lines.append("• История цены объявления?")
52
+ lines.append("• Кто собственник, сколько владеет?")
53
+ lines.append("• Есть обременения/аресты?")
54
+ lines.append("• Альтернатива? Ключи/показы в будни?")
55
+ lines.append("• Торг возможен?")
56
+ if t["flags"]:
57
+ lines.append("⚠ Аккуратно: " + ", ".join(f["code"] for f in t["flags"]) + " — уточнить статус")
58
+ lines.append("")
59
+ return "\n".join(lines).strip()
60
+
61
+
62
+ def render_telegram(text: str) -> str:
63
+ return text
nedviga/chains.py ADDED
@@ -0,0 +1,83 @@
1
+ """v0.5.4 (the task brief) deal chains."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from . import pipeline
9
+
10
+
11
+ def _connect(db=None):
12
+ conn = pipeline._connect(db)
13
+ conn.execute("""CREATE TABLE IF NOT EXISTS deal_structures (
14
+ card_id TEXT PRIMARY KEY, kind TEXT NOT NULL, created TEXT NOT NULL)""")
15
+ conn.execute("""CREATE TABLE IF NOT EXISTS chain_links (
16
+ a TEXT NOT NULL, b TEXT NOT NULL, rel TEXT NOT NULL,
17
+ UNIQUE(a,b,rel))""")
18
+ conn.commit()
19
+ return conn
20
+
21
+
22
+ def chain_new(card_id: str, kind: str, db=None) -> dict:
23
+ conn = _connect(db)
24
+ try:
25
+ conn.execute("INSERT OR REPLACE INTO deal_structures (card_id, kind, created) VALUES (?,?,?)",
26
+ (card_id, kind, pipeline._now()))
27
+ conn.commit()
28
+ return {"ok": True, "card_id": card_id, "kind": kind}
29
+ finally:
30
+ conn.close()
31
+
32
+
33
+ def _has_path(start: str, target: str, db=None) -> bool:
34
+ conn = _connect(db)
35
+ try:
36
+ # BFS over links a->b
37
+ seen = set()
38
+ stack = [start]
39
+ while stack:
40
+ cur = stack.pop()
41
+ if cur == target:
42
+ return True
43
+ if cur in seen:
44
+ continue
45
+ seen.add(cur)
46
+ rows = conn.execute("SELECT b FROM chain_links WHERE a=?", (cur,)).fetchall()
47
+ for r in rows:
48
+ if r["b"] not in seen:
49
+ stack.append(r["b"])
50
+ return False
51
+ finally:
52
+ conn.close()
53
+
54
+
55
+ def chain_link(a: str, b: str, rel: str, db=None) -> dict:
56
+ if _has_path(b, a, db=db):
57
+ return {"ok": False, "reason": "cycle_detected"}
58
+ conn = _connect(db)
59
+ try:
60
+ conn.execute("INSERT OR REPLACE INTO chain_links (a,b,rel) VALUES (?,?,?)", (a, b, rel))
61
+ conn.commit()
62
+ return {"ok": True, "a": a, "b": b, "rel": rel}
63
+ finally:
64
+ conn.close()
65
+
66
+
67
+ def chain_show(card_id: str, db=None) -> dict:
68
+ conn = _connect(db)
69
+ try:
70
+ row = conn.execute("SELECT * FROM deal_structures WHERE card_id=?", (card_id,)).fetchone()
71
+ links = conn.execute("SELECT * FROM chain_links WHERE a=? OR b=?", (card_id, card_id)).fetchall()
72
+ # gather up to 10 nodes
73
+ nodes = {card_id}
74
+ for r in links:
75
+ nodes.add(r["a"]); nodes.add(r["b"])
76
+ nodes = list(nodes)[:10]
77
+ risk = None
78
+ if row and row["kind"] == "alt_unfound":
79
+ risk = "risk: развал вероятен — альтернатива не подобрана"
80
+ return {"card_id": card_id, "kind": row["kind"] if row else None,
81
+ "nodes": nodes, "links": [dict(r) for r in links], "risk": risk}
82
+ finally:
83
+ conn.close()
nedviga/checklist.py ADDED
@@ -0,0 +1,119 @@
1
+ """v0.5.0 (the task brief) checklist."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ import os
9
+ import subprocess
10
+ from pathlib import Path as _Path
11
+
12
+ from .legal_triage import load_legal_data, triage
13
+ from .models import ListingCard
14
+
15
+
16
+ def _load_card(card_id: str, file: str) -> ListingCard:
17
+ raw = json.loads(Path(file).read_text(encoding="utf-8"))
18
+ if isinstance(raw, dict):
19
+ if "cards" in raw and isinstance(raw["cards"], list):
20
+ raw = raw["cards"]
21
+ elif "data" in raw:
22
+ d = raw["data"]
23
+ raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
24
+ else:
25
+ raw = [raw]
26
+ if not isinstance(raw, list):
27
+ raw = [raw]
28
+ for item in raw:
29
+ if isinstance(item, dict) and item.get("id") == card_id:
30
+ return ListingCard(**item)
31
+ if raw and isinstance(raw[0], dict):
32
+ return ListingCard(**raw[0])
33
+ raise ValueError("card not found")
34
+
35
+
36
+ def new_checklist(card_id: str, file: str) -> str:
37
+ card = _load_card(card_id, file)
38
+ data = load_legal_data()
39
+ t = triage(card)
40
+ lines = [f"# Чек-лист юридической проверки — {card_id}", ""]
41
+ lines.append(data.get("disclaimer", ""))
42
+ lines.append("")
43
+ lines.append("## Триаж (red flags)")
44
+ if t["flags"]:
45
+ for f in t["flags"]:
46
+ lines.append(f"- ⚠ {f['code']} ({f['severity']}): {f['text_basis']}")
47
+ lines.append(f" Проверка: {f['check']}")
48
+ else:
49
+ lines.append("- Маркеры не выявлены (предварительно).")
50
+ lines.append("")
51
+ lines.append("## Пункты проверки")
52
+ for i, item in enumerate(data.get("checklist", []), 1):
53
+ lines.append(f"- [ ] {item}")
54
+ return "\n".join(lines)
55
+
56
+
57
+ def _a2a_bin() -> str:
58
+ return os.environ.get("A2A_BIN", "a2a")
59
+
60
+
61
+ def _drafts_dir() -> Path:
62
+ base = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
63
+ d = base / "drafts"
64
+ d.mkdir(parents=True, exist_ok=True)
65
+ return d
66
+
67
+
68
+ def handoff(card_id: str, to: str, file: Optional[str] = None) -> dict:
69
+ from .models import ListingCard
70
+ import json as _json
71
+
72
+ card = None
73
+ if file:
74
+ try:
75
+ card = _load_card(card_id, file)
76
+ except Exception:
77
+ card = None
78
+ data = load_legal_data()
79
+ t = triage(card) if card is not None else {"flags": [], "disclaimer": data.get("disclaimer")}
80
+ # build obfuscated fact block
81
+ price = None
82
+ address = None
83
+ if card is not None:
84
+ price = card.pricing.price_total_rub
85
+ address = card.building.address_raw
86
+ fact_lines = [f"Карточка: {card_id}"]
87
+ if address:
88
+ fact_lines.append(f"Адрес: {address}")
89
+ if price is not None:
90
+ fact_lines.append(f"Цена: {price}")
91
+ fact_lines.append("Флаги triage:")
92
+ for f in t.get("flags", []):
93
+ fact_lines.append(f"- {f['code']} ({f['severity']}): {f['text_basis']}")
94
+ if not t.get("flags"):
95
+ fact_lines.append("- (нет выявленных маркеров)")
96
+ fact_lines.append("")
97
+ fact_lines.append("Чек-лист:")
98
+ for item in data.get("checklist", []):
99
+ fact_lines.append(f"- [ ] {item}")
100
+ fact_lines.append("")
101
+ fact_lines.append(data.get("disclaimer", ""))
102
+ body = "\n".join(fact_lines)
103
+ subject = f"Handoff: юридическая проверка {card_id}"
104
+
105
+ draft_path = _drafts_dir() / f"{card_id}.md"
106
+ draft_path.write_text(body, encoding="utf-8")
107
+
108
+ bin_path = _a2a_bin()
109
+ try:
110
+ proc = subprocess.run(
111
+ [bin_path, "mail", "send", "--to", to, "--subject", subject, "--body", body],
112
+ capture_output=True, text=True, timeout=30,
113
+ )
114
+ except Exception as exc:
115
+ return {"ok": False, "reason": f"a2a_unavailable: {exc}", "draft": str(draft_path)}
116
+ if proc.returncode != 0:
117
+ return {"ok": False, "reason": f"a2a_error: {proc.stderr.strip() or proc.stdout.strip()}",
118
+ "draft": str(draft_path), "returncode": proc.returncode}
119
+ return {"ok": True, "draft": str(draft_path), "stdout": proc.stdout.strip()}