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.
- nedviga/__init__.py +46 -0
- nedviga/__main__.py +6 -0
- nedviga/_wm.py +93 -0
- nedviga/adaptive_selectors.py +100 -0
- nedviga/auditlog.py +77 -0
- nedviga/auto.py +88 -0
- nedviga/bargain.py +183 -0
- nedviga/browser_native.py +242 -0
- nedviga/callsheet.py +63 -0
- nedviga/chains.py +83 -0
- nedviga/checklist.py +119 -0
- nedviga/cian_card.py +1371 -0
- nedviga/cian_live.py +812 -0
- nedviga/cli.py +3118 -0
- nedviga/compare.py +204 -0
- nedviga/data/legal_flags.json +146 -0
- nedviga/data/release_pubkey.asc +22 -0
- nedviga/deck.py +115 -0
- nedviga/dedup.py +242 -0
- nedviga/developer.py +169 -0
- nedviga/digest.py +205 -0
- nedviga/doctor.py +128 -0
- nedviga/dossier.py +303 -0
- nedviga/eco.py +81 -0
- nedviga/errors.py +22 -0
- nedviga/export_html.py +280 -0
- nedviga/export_pdf.py +96 -0
- nedviga/family_card.py +670 -0
- nedviga/feedback.py +79 -0
- nedviga/geo_routing.py +214 -0
- nedviga/graph.py +149 -0
- nedviga/legal_triage.py +105 -0
- nedviga/litprecheck.py +116 -0
- nedviga/market.py +102 -0
- nedviga/mcp_server.py +195 -0
- nedviga/metro_directory.json +9836 -0
- nedviga/metro_vestibules.py +134 -0
- nedviga/models.py +164 -0
- nedviga/mosdata.py +95 -0
- nedviga/net_guard.py +997 -0
- nedviga/noise.py +156 -0
- nedviga/ops.py +94 -0
- nedviga/outcomes.py +35 -0
- nedviga/pii.py +85 -0
- nedviga/pipeline.py +239 -0
- nedviga/poi.py +180 -0
- nedviga/price_guard.py +73 -0
- nedviga/profiles.py +131 -0
- nedviga/projects.py +112 -0
- nedviga/purge.py +116 -0
- nedviga/py.typed +0 -0
- nedviga/risk.py +127 -0
- nedviga/roles.py +124 -0
- nedviga/routing_cache.py +72 -0
- nedviga/rush.py +139 -0
- nedviga/scorefit.py +127 -0
- nedviga/sources.py +55 -0
- nedviga/sources_avito.py +138 -0
- nedviga/sources_domclick.py +122 -0
- nedviga/stale.py +83 -0
- nedviga/textsearch.py +141 -0
- nedviga/tour.py +180 -0
- nedviga/watch.py +234 -0
- nedviga/wizard.py +106 -0
- nedviga_cli-0.1.0.dist-info/METADATA +130 -0
- nedviga_cli-0.1.0.dist-info/RECORD +71 -0
- nedviga_cli-0.1.0.dist-info/WHEEL +5 -0
- nedviga_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nedviga_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
- nedviga_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
- 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()}
|