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
nedviga/feedback.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""v0.5.4 (the task brief) feedback tracker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from . import pipeline
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _connect(db=None):
|
|
14
|
+
conn = pipeline._connect(db)
|
|
15
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS show_feedback (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
card_id TEXT NOT NULL,
|
|
18
|
+
verdict TEXT NOT NULL,
|
|
19
|
+
reason TEXT,
|
|
20
|
+
note TEXT,
|
|
21
|
+
created_at TEXT NOT NULL,
|
|
22
|
+
snapshot TEXT)""")
|
|
23
|
+
conn.commit()
|
|
24
|
+
return conn
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def add_feedback(card_id: str, verdict: str, reason: Optional[str] = None, note: Optional[str] = None, dry: bool = False, db=None) -> dict:
|
|
28
|
+
if dry:
|
|
29
|
+
return {"ok": True, "dry": True, "card_id": card_id, "verdict": verdict}
|
|
30
|
+
conn = _connect(db)
|
|
31
|
+
try:
|
|
32
|
+
conn.execute("INSERT INTO show_feedback (card_id, verdict, reason, note, created_at, snapshot) VALUES (?,?,?,?,?,?)",
|
|
33
|
+
(card_id, verdict, reason, note, datetime.now(timezone.utc).isoformat(), None))
|
|
34
|
+
conn.commit()
|
|
35
|
+
return {"ok": True, "card_id": card_id, "verdict": verdict}
|
|
36
|
+
finally:
|
|
37
|
+
conn.close()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def list_feedback(card_id: Optional[str] = None, db=None) -> list[dict]:
|
|
41
|
+
conn = _connect(db)
|
|
42
|
+
try:
|
|
43
|
+
if card_id:
|
|
44
|
+
rows = conn.execute("SELECT * FROM show_feedback WHERE card_id=? ORDER BY id", (card_id,)).fetchall()
|
|
45
|
+
else:
|
|
46
|
+
rows = conn.execute("SELECT * FROM show_feedback ORDER BY id").fetchall()
|
|
47
|
+
return [dict(r) for r in rows]
|
|
48
|
+
finally:
|
|
49
|
+
conn.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def profile_aggregate(db=None) -> dict[str, Any]:
|
|
53
|
+
items = list_feedback(db=db)
|
|
54
|
+
n = len(items)
|
|
55
|
+
suggested = []
|
|
56
|
+
if n >= 5:
|
|
57
|
+
# naive: one aggregate weight
|
|
58
|
+
liked = sum(1 for x in items if x.get("verdict") == "liked")
|
|
59
|
+
rejected = sum(1 for x in items if x.get("verdict") == "rejected")
|
|
60
|
+
suggested.append({"feature": "overall", "delta": (liked - rejected) / n, "n_backing": n})
|
|
61
|
+
# features with n<5 omitted
|
|
62
|
+
return {"n_feedback": n, "suggested_weights": suggested}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _overrides_path() -> Path:
|
|
66
|
+
base = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
67
|
+
return base / "profile_overrides.json"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def apply_overrides(dry: bool = False, yes: bool = False, db=None) -> dict:
|
|
71
|
+
prof = profile_aggregate(db=db)
|
|
72
|
+
if dry:
|
|
73
|
+
return {"ok": True, "dry": True, "proposed": prof["suggested_weights"]}
|
|
74
|
+
if not yes:
|
|
75
|
+
return {"ok": False, "reason": "requires_yes"}
|
|
76
|
+
path = _overrides_path()
|
|
77
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
path.write_text(json.dumps(prof["suggested_weights"], ensure_ascii=False, indent=2), encoding="utf-8")
|
|
79
|
+
return {"ok": True, "written": str(path), "overrides": prof["suggested_weights"]}
|
nedviga/geo_routing.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Honest walking-distance router to the nearest Moscow metro vestibule.
|
|
2
|
+
|
|
3
|
+
Public OSRM demo server is used when available; otherwise the module
|
|
4
|
+
falls back to a haversine estimate with an urban winding factor.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from .metro_vestibules import get_vestibules
|
|
15
|
+
|
|
16
|
+
OSRM_BASE_URL = "https://router.project-osrm.org/route/v1"
|
|
17
|
+
OSRM_TIMEOUT_SECONDS = 15
|
|
18
|
+
|
|
19
|
+
WALK_SPEED_M_PER_MIN = 80.0 # 4.8 km/h
|
|
20
|
+
URBAN_HAVERSINE_FACTOR = 1.35
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _normalize_station_name(name: str) -> str:
|
|
24
|
+
return name.strip().casefold().replace("ё", "е")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
28
|
+
"""Great-circle distance between two WGS-84 points in meters."""
|
|
29
|
+
earth_radius_m = 6_371_000.0
|
|
30
|
+
phi1 = math.radians(lat1)
|
|
31
|
+
phi2 = math.radians(lat2)
|
|
32
|
+
dphi = math.radians(lat2 - lat1)
|
|
33
|
+
dlambda = math.radians(lon2 - lon1)
|
|
34
|
+
|
|
35
|
+
a = (
|
|
36
|
+
math.sin(dphi / 2) ** 2
|
|
37
|
+
+ math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
|
38
|
+
)
|
|
39
|
+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
|
40
|
+
return earth_radius_m * c
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _duration_minutes(distance_meters: float) -> int:
|
|
44
|
+
"""Walking duration at 4.8 km/h (80 m/min), rounded up."""
|
|
45
|
+
return math.ceil(distance_meters / WALK_SPEED_M_PER_MIN)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _osrm_distance(
|
|
49
|
+
lat1: float, lon1: float, lat2: float, lon2: float, profile: str
|
|
50
|
+
) -> float:
|
|
51
|
+
"""Ask OSRM for a route distance in meters.
|
|
52
|
+
|
|
53
|
+
Raises ``requests.RequestException`` or ``RuntimeError`` on any failure.
|
|
54
|
+
"""
|
|
55
|
+
url = (
|
|
56
|
+
f"{OSRM_BASE_URL}/{profile}/{lon1},{lat1};{lon2},{lat2}"
|
|
57
|
+
f"?overview=false&alternatives=false&steps=false"
|
|
58
|
+
)
|
|
59
|
+
# v0.3.5: netguard (egress-guard + breaker) на внешние маршрутизаторы
|
|
60
|
+
from .net_guard import guarded_get
|
|
61
|
+
|
|
62
|
+
response = guarded_get(url, timeout=OSRM_TIMEOUT_SECONDS)
|
|
63
|
+
response.raise_for_status()
|
|
64
|
+
payload = response.json()
|
|
65
|
+
|
|
66
|
+
if payload.get("code") != "Ok":
|
|
67
|
+
message = payload.get("message", "unknown OSRM error")
|
|
68
|
+
raise RuntimeError(f"OSRM {profile} error: {message}")
|
|
69
|
+
|
|
70
|
+
return float(payload["routes"][0]["distance"])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _route_distance(
|
|
74
|
+
lat1: float, lon1: float, lat2: float, lon2: float
|
|
75
|
+
) -> tuple[float, str, bool]:
|
|
76
|
+
"""Return (distance_meters, provider, route_verified).
|
|
77
|
+
|
|
78
|
+
Provider ladder (v0.2.0, self-contained):
|
|
79
|
+
1. graph_local — встроенный пешеходный граф Москвы (route_verified=True)
|
|
80
|
+
2. cache — ранее посчитанный маршрут (route_verified=True, cached=True)
|
|
81
|
+
3. osrm_foot / osrm_driving_fallback — legacy, только если граф недоступен
|
|
82
|
+
(публичный OSRM не является зависимостью)
|
|
83
|
+
4. haversine_fallback — прикидка
|
|
84
|
+
"""
|
|
85
|
+
# 1. Локальный граф
|
|
86
|
+
try:
|
|
87
|
+
from . import graph as _graph_mod
|
|
88
|
+
|
|
89
|
+
res = _graph_mod.route(lat1, lon1, lat2, lon2)
|
|
90
|
+
if res is not None:
|
|
91
|
+
meters, provider = res
|
|
92
|
+
return meters, "graph_local", True
|
|
93
|
+
except Exception:
|
|
94
|
+
pass # граф не установлен/не загрузился — идём дальше по лестнице
|
|
95
|
+
|
|
96
|
+
# 2. OSRM (legacy path; не является обязательной инфрой)
|
|
97
|
+
try:
|
|
98
|
+
distance = _osrm_distance(lat1, lon1, lat2, lon2, "foot")
|
|
99
|
+
return distance, "osrm_foot", True
|
|
100
|
+
except Exception:
|
|
101
|
+
try:
|
|
102
|
+
distance = _osrm_distance(lat1, lon1, lat2, lon2, "driving")
|
|
103
|
+
return distance, "osrm_driving_fallback", False
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
# 3. Haversine
|
|
108
|
+
distance = _haversine(lat1, lon1, lat2, lon2) * URBAN_HAVERSINE_FACTOR
|
|
109
|
+
return distance, "haversine_fallback", False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def walk_route(lat: float, lon: float, target_station: str) -> dict[str, Any]:
|
|
113
|
+
"""Route from (lat, lon) to the nearest vestibule of ``target_station``.
|
|
114
|
+
|
|
115
|
+
Returns a dict that follows the ``HonestWalkRoute`` schema with a few
|
|
116
|
+
extra operational keys:
|
|
117
|
+
|
|
118
|
+
* ``target_station`` – requested station name
|
|
119
|
+
* ``target_vestibule`` – name of the nearest vestibule
|
|
120
|
+
* ``walk_distance_meters`` – integer meters
|
|
121
|
+
* ``walk_duration_minutes`` – integer minutes at 4.8 km/h
|
|
122
|
+
* ``routing_provider`` – e.g. ``osrm_foot``, ``osrm_driving``,
|
|
123
|
+
``haversine_fallback``
|
|
124
|
+
* ``route_verified`` – ``True`` when OSRM returned a real route
|
|
125
|
+
* ``status`` – ``ok`` or ``degraded``
|
|
126
|
+
* ``reason`` – ``None`` normally, ``STATION_NOT_IN_DIRECTORY`` for unknown
|
|
127
|
+
stations
|
|
128
|
+
"""
|
|
129
|
+
vestibules = get_vestibules(target_station)
|
|
130
|
+
if vestibules is None:
|
|
131
|
+
return {
|
|
132
|
+
"target_station": target_station,
|
|
133
|
+
"target_vestibule": None,
|
|
134
|
+
"walk_distance_meters": None,
|
|
135
|
+
"walk_duration_minutes": None,
|
|
136
|
+
"routing_provider": "station_directory",
|
|
137
|
+
"route_verified": False,
|
|
138
|
+
"status": "degraded",
|
|
139
|
+
"reason": "STATION_NOT_IN_DIRECTORY",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# 0. Кеш: ранее посчитанный маршрут (station-level, до перебора вестибюлей)
|
|
143
|
+
try:
|
|
144
|
+
from . import routing_cache
|
|
145
|
+
|
|
146
|
+
cached = routing_cache.get(lat, lon, target_station)
|
|
147
|
+
if cached is not None:
|
|
148
|
+
return {
|
|
149
|
+
"target_station": target_station,
|
|
150
|
+
"target_vestibule": None,
|
|
151
|
+
"walk_distance_meters": int(round(cached)),
|
|
152
|
+
"walk_duration_minutes": _duration_minutes(cached),
|
|
153
|
+
"routing_provider": "cache",
|
|
154
|
+
"route_verified": True,
|
|
155
|
+
"status": "ok",
|
|
156
|
+
"reason": None,
|
|
157
|
+
"cached": True,
|
|
158
|
+
}
|
|
159
|
+
except Exception:
|
|
160
|
+
pass # кеш не критичен
|
|
161
|
+
|
|
162
|
+
best: dict[str, Any] | None = None
|
|
163
|
+
|
|
164
|
+
for vestibule_name, vlat, vlon in vestibules:
|
|
165
|
+
distance, provider, verified = _route_distance(lat, lon, vlat, vlon)
|
|
166
|
+
if best is None or distance < best["distance"]:
|
|
167
|
+
best = {
|
|
168
|
+
"distance": distance,
|
|
169
|
+
"provider": provider,
|
|
170
|
+
"verified": verified,
|
|
171
|
+
"vestibule": vestibule_name,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
assert best is not None
|
|
175
|
+
|
|
176
|
+
distance_m = int(round(best["distance"]))
|
|
177
|
+
duration_min = _duration_minutes(best["distance"])
|
|
178
|
+
|
|
179
|
+
result: dict[str, Any] = {
|
|
180
|
+
"target_station": target_station,
|
|
181
|
+
"target_vestibule": best["vestibule"],
|
|
182
|
+
"walk_distance_meters": distance_m,
|
|
183
|
+
"walk_duration_minutes": duration_min,
|
|
184
|
+
"routing_provider": best["provider"],
|
|
185
|
+
"route_verified": best["verified"],
|
|
186
|
+
"status": "ok",
|
|
187
|
+
"reason": None,
|
|
188
|
+
}
|
|
189
|
+
if not best["verified"]:
|
|
190
|
+
result["fallback_reason"] = "osrm_unavailable"
|
|
191
|
+
# Кешируем успешный маршрут (graph_local / osrm_foot) для будущих запросов
|
|
192
|
+
if best["provider"] in ("graph_local", "osrm_foot"):
|
|
193
|
+
try:
|
|
194
|
+
from . import routing_cache
|
|
195
|
+
|
|
196
|
+
routing_cache.put(lat, lon, target_station, float(distance_m),
|
|
197
|
+
provider=best["provider"])
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def honest_overclaim(honest_minutes: int, claimed_minutes: int) -> dict[str, Any]:
|
|
204
|
+
"""Compare honest walking time with a platform claim.
|
|
205
|
+
|
|
206
|
+
Returns ``{"overclaim_minutes": N, "flag": bool}`` where ``flag`` is
|
|
207
|
+
``True`` when the honest time exceeds the claimed time by more than
|
|
208
|
+
three minutes.
|
|
209
|
+
"""
|
|
210
|
+
overclaim = max(0, honest_minutes - claimed_minutes)
|
|
211
|
+
return {
|
|
212
|
+
"overclaim_minutes": overclaim,
|
|
213
|
+
"flag": honest_minutes > claimed_minutes + 3,
|
|
214
|
+
}
|
nedviga/graph.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Локальный пешеходный граф Москвы: загрузка, снап, A*.
|
|
2
|
+
|
|
3
|
+
Граф поставляется артефактом (pickle, см. tools/build_graph.py) и кладётся
|
|
4
|
+
в nedviga/graph_moscow.pkg или по пути из NEDVIGA_GRAPH_PATH.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import heapq
|
|
9
|
+
import math
|
|
10
|
+
import pickle
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# Приоритет путей поиска граф-файла
|
|
14
|
+
DEFAULT_GRAPH_PATHS = [
|
|
15
|
+
Path(__file__).parent / "graph_moscow.pkg",
|
|
16
|
+
Path(__file__).parent.parent / "graph_moscow.pkg",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
_walking_speed_m_per_min = 80.0 # 4.8 km/h, согласовано с SPEC
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GraphNotAvailable(Exception):
|
|
23
|
+
"""Граф не найден/не загружен."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Graph:
|
|
27
|
+
__slots__ = ("nodes", "edges", "adj", "version", "built_at", "_grid_cache")
|
|
28
|
+
|
|
29
|
+
def __init__(self, nodes: dict, edges: dict, version: int, built_at: str):
|
|
30
|
+
self.nodes = nodes # {id: (lat, lon)}
|
|
31
|
+
self.edges = edges # {id: [(id, meters), ...]}
|
|
32
|
+
self.version = version
|
|
33
|
+
self.built_at = built_at
|
|
34
|
+
self.adj = edges # alias
|
|
35
|
+
self._grid_cache = None
|
|
36
|
+
|
|
37
|
+
def nearest_node(self, lat: float, lon: float, max_m: float = 400.0) -> int | None:
|
|
38
|
+
"""Ближайший узел графа. Линейный поиск приемлем: узлов ~200-400k,
|
|
39
|
+
но для скорости — сетка-индекс."""
|
|
40
|
+
# Простой грид-индекс
|
|
41
|
+
cell = 0.005 # ~500 м
|
|
42
|
+
best, best_d = None, max_m
|
|
43
|
+
la0, la1 = math.floor((lat - max_m/111000) / cell), math.ceil((lat + max_m/111000) / cell)
|
|
44
|
+
lo0, lo1 = math.floor((lon - max_m/111000) / (cell * 0.6)), math.ceil((lon + max_m/111000) / (cell * 0.6))
|
|
45
|
+
grid = self._grid()
|
|
46
|
+
for ga in range(la0, la1 + 1):
|
|
47
|
+
for go in range(lo0, lo1 + 1):
|
|
48
|
+
for nid in grid.get((ga, go), ()): # noqa
|
|
49
|
+
nla, nlo = self.nodes[nid]
|
|
50
|
+
d = _haversine(lat, lon, nla, nlo)
|
|
51
|
+
if d < best_d:
|
|
52
|
+
best, best_d = nid, d
|
|
53
|
+
return best
|
|
54
|
+
|
|
55
|
+
def _grid(self) -> dict:
|
|
56
|
+
if self._grid_cache is None:
|
|
57
|
+
g: dict[tuple[int, int], list[int]] = {}
|
|
58
|
+
cell = 0.005
|
|
59
|
+
for nid, (la, lo) in self.nodes.items():
|
|
60
|
+
g.setdefault((math.floor(la / cell), math.floor(lo / (cell * 0.6))), []).append(nid)
|
|
61
|
+
self._grid_cache = g
|
|
62
|
+
return self._grid_cache
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_graph: _Graph | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _haversine(lat1, lon1, lat2, lon2):
|
|
69
|
+
r = 6_371_000.0
|
|
70
|
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
71
|
+
dp = math.radians(lat2 - lat1)
|
|
72
|
+
dl = math.radians(lon2 - lon1)
|
|
73
|
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
74
|
+
return 2 * r * math.asin(math.sqrt(a))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_graph(explicit: Path | None = None) -> _Graph:
|
|
78
|
+
"""Загрузить граф (однократно). Path: explicit > NEDVIGA_GRAPH_PATH > дефолты."""
|
|
79
|
+
global _graph
|
|
80
|
+
if _graph is not None:
|
|
81
|
+
return _graph
|
|
82
|
+
import os
|
|
83
|
+
|
|
84
|
+
candidates = []
|
|
85
|
+
if explicit:
|
|
86
|
+
candidates.append(explicit)
|
|
87
|
+
env = os.environ.get("NEDVIGA_GRAPH_PATH")
|
|
88
|
+
if env:
|
|
89
|
+
candidates.append(Path(env))
|
|
90
|
+
candidates.extend(DEFAULT_GRAPH_PATHS)
|
|
91
|
+
import hashlib
|
|
92
|
+
for c in candidates:
|
|
93
|
+
if c.exists():
|
|
94
|
+
# v0.4.5 (review R14/R9): pickle = RCE при подмене файла.
|
|
95
|
+
# Грузим только с валидным sidecar .sha256; осознанный обход — явным env.
|
|
96
|
+
sidecar = Path(str(c) + ".sha256")
|
|
97
|
+
allow_unsigned = os.environ.get("NEDVIGA_GRAPH_ALLOW_UNSIGNED") == "1"
|
|
98
|
+
if sidecar.exists():
|
|
99
|
+
want = sidecar.read_text(encoding="ascii").split()[0].lower()
|
|
100
|
+
got = hashlib.sha256(c.read_bytes()).hexdigest()
|
|
101
|
+
if want != got:
|
|
102
|
+
raise GraphNotAvailable(f"graph-артефакт повреждён: sha256 {got[:12]} != {want[:12]} (подмена?)")
|
|
103
|
+
elif not allow_unsigned:
|
|
104
|
+
raise GraphNotAvailable(
|
|
105
|
+
f"{c} без sidecar .sha256 — pickle без верификации запрещён "
|
|
106
|
+
f"(сгенерируй: sha256sum pkg > pkg.sha256, или осознанно NEDVIGA_GRAPH_ALLOW_UNSIGNED=1)")
|
|
107
|
+
with open(c, "rb") as f:
|
|
108
|
+
data = pickle.load(f)
|
|
109
|
+
_graph = _Graph(data["nodes"], data["edges"], data.get("version", 1), data.get("built_at", ""))
|
|
110
|
+
return _graph
|
|
111
|
+
raise GraphNotAvailable("graph_moscow.pkg не найден (NEDVIGA_GRAPH_PATH или рядом с пакетом)")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def route(lat: float, lon: float, mlat: float, mlon: float) -> tuple[float, str] | None:
|
|
115
|
+
"""Пешее расстояние между двумя координатами по графу. (meters, provider) или None."""
|
|
116
|
+
g = load_graph()
|
|
117
|
+
s = g.nearest_node(lat, lon)
|
|
118
|
+
t = g.nearest_node(mlat, mlon)
|
|
119
|
+
if s is None or t is None:
|
|
120
|
+
return None
|
|
121
|
+
if s == t:
|
|
122
|
+
return 0.0, "graph_local"
|
|
123
|
+
# A* по haversine-эвристике
|
|
124
|
+
dist: dict[int, float] = {s: 0.0}
|
|
125
|
+
pq = [(0.0, s)]
|
|
126
|
+
seen: set[int] = set()
|
|
127
|
+
while pq:
|
|
128
|
+
_, u = heapq.heappop(pq)
|
|
129
|
+
if u in seen:
|
|
130
|
+
continue
|
|
131
|
+
seen.add(u)
|
|
132
|
+
if u == t:
|
|
133
|
+
meters = dist[t]
|
|
134
|
+
# + снап-хвосты до реальных точек
|
|
135
|
+
nla, nlo = g.nodes[s]
|
|
136
|
+
mla, mlo = g.nodes[t]
|
|
137
|
+
meters += _haversine(lat, lon, nla, nlo) + _haversine(mla, mlo, mlat, mlo)
|
|
138
|
+
return round(meters, 1), "graph_local"
|
|
139
|
+
for v, m in g.adj.get(u, ()):
|
|
140
|
+
if v in seen:
|
|
141
|
+
continue
|
|
142
|
+
nd = dist[u] + m
|
|
143
|
+
if v not in dist or nd < dist[v]:
|
|
144
|
+
dist[v] = nd
|
|
145
|
+
# эвристика: прямая до цели
|
|
146
|
+
hla, hlo = g.nodes[v]
|
|
147
|
+
h = _haversine(hla, hlo, mlat, mlon)
|
|
148
|
+
heapq.heappush(pq, (nd + h, v))
|
|
149
|
+
return None
|
nedviga/legal_triage.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""v0.5.0 (the task brief) legal red-flag triage (data-driven)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from .models import ListingCard
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _data_path() -> Path:
|
|
12
|
+
return Path(__file__).resolve().parent / "data" / "legal_flags.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_legal_data() -> dict[str, Any]:
|
|
16
|
+
return json.loads(_data_path().read_text(encoding="utf-8"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _listing(card: Any) -> ListingCard:
|
|
20
|
+
if isinstance(card, ListingCard):
|
|
21
|
+
return card
|
|
22
|
+
if isinstance(card, dict):
|
|
23
|
+
return ListingCard(**card)
|
|
24
|
+
if isinstance(card, str):
|
|
25
|
+
import json as _j
|
|
26
|
+
raw = _j.loads(Path(card).read_text(encoding="utf-8"))
|
|
27
|
+
if isinstance(raw, list):
|
|
28
|
+
raw = raw[0]
|
|
29
|
+
if isinstance(raw, dict) and "cards" in raw:
|
|
30
|
+
raw = raw["cards"][0]
|
|
31
|
+
return ListingCard(**raw)
|
|
32
|
+
raise TypeError("unsupported card")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _card_text(card: ListingCard) -> str:
|
|
36
|
+
parts = [card.title or "", card.description_text or "", card.building.building_type or "",
|
|
37
|
+
card.building.address_raw or "", card.category or ""]
|
|
38
|
+
# include seller type etc
|
|
39
|
+
if card.seller and card.seller.seller_type:
|
|
40
|
+
parts.append(card.seller.seller_type)
|
|
41
|
+
return " ".join(p for p in parts if p)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _quote(text: str, marker: str) -> str:
|
|
45
|
+
low = text.lower()
|
|
46
|
+
idx = low.find(marker.lower())
|
|
47
|
+
if idx < 0:
|
|
48
|
+
return marker
|
|
49
|
+
start = max(0, idx - 60)
|
|
50
|
+
end = min(len(text), idx + len(marker) + 60)
|
|
51
|
+
snippet = text[start:end].strip()
|
|
52
|
+
# trim to word boundaries roughly
|
|
53
|
+
return snippet if snippet else marker
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _severity(flag: dict, text: str) -> str:
|
|
57
|
+
sev = flag.get("severity", "amber")
|
|
58
|
+
rule = flag.get("severity_rule")
|
|
59
|
+
if not rule:
|
|
60
|
+
return sev
|
|
61
|
+
# conditional
|
|
62
|
+
low = text.lower()
|
|
63
|
+
if rule.get("if_text") in low:
|
|
64
|
+
# find number before 'лет'
|
|
65
|
+
import re
|
|
66
|
+
m = re.search(r"(\d+)\s*лет", low)
|
|
67
|
+
if m:
|
|
68
|
+
years = int(m.group(1))
|
|
69
|
+
threshold = rule.get("threshold_years", 3)
|
|
70
|
+
if years < threshold and rule.get("red_if_under"):
|
|
71
|
+
return "red"
|
|
72
|
+
return rule.get("else", "amber")
|
|
73
|
+
return rule.get("else", sev)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def triage(card: Any) -> dict[str, Any]:
|
|
77
|
+
data = load_legal_data()
|
|
78
|
+
c = _listing(card)
|
|
79
|
+
text = _card_text(c)
|
|
80
|
+
low = text.lower()
|
|
81
|
+
flags = []
|
|
82
|
+
for flag in data.get("flags", []):
|
|
83
|
+
markers = flag.get("markers", [])
|
|
84
|
+
found_marker = None
|
|
85
|
+
for m in markers:
|
|
86
|
+
if m.lower() in low:
|
|
87
|
+
found_marker = m
|
|
88
|
+
break
|
|
89
|
+
if not found_marker:
|
|
90
|
+
continue
|
|
91
|
+
text_basis = _quote(text, found_marker)
|
|
92
|
+
sev = _severity(flag, text)
|
|
93
|
+
flags.append({
|
|
94
|
+
"code": flag["code"],
|
|
95
|
+
"label": flag.get("title"),
|
|
96
|
+
"severity": sev,
|
|
97
|
+
"text_basis": text_basis,
|
|
98
|
+
"check": flag.get("check"),
|
|
99
|
+
})
|
|
100
|
+
return {
|
|
101
|
+
"flags": flags,
|
|
102
|
+
"disclaimer": data.get("disclaimer"),
|
|
103
|
+
"legal_data_version": data.get("version"),
|
|
104
|
+
"approved_by": data.get("approved_by"),
|
|
105
|
+
}
|
nedviga/litprecheck.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""v0.5.6 (the task brief) litigation pre-check via sud-cli."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from . import pipeline
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sud_bin() -> str:
|
|
16
|
+
return os.environ.get("SUD_BIN", "sud-cli")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cache_dir() -> Path:
|
|
20
|
+
base = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
21
|
+
d = base / "litcheck_cache"
|
|
22
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
return d
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cache_key(query: str) -> str:
|
|
27
|
+
return hashlib.sha256(query.encode()).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cache_get(key: str) -> Optional[dict]:
|
|
31
|
+
p = _cache_dir() / f"{key}.json"
|
|
32
|
+
if not p.exists():
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
|
38
|
+
ts = data.get("_ts")
|
|
39
|
+
if ts:
|
|
40
|
+
dt = datetime.fromisoformat(ts)
|
|
41
|
+
if datetime.now(timezone.utc) - dt > timedelta(hours=24):
|
|
42
|
+
return None
|
|
43
|
+
return data.get("payload")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cache_set(key: str, payload: dict) -> None:
|
|
47
|
+
p = _cache_dir() / f"{key}.json"
|
|
48
|
+
p.write_text(json.dumps({"_ts": datetime.now(timezone.utc).isoformat(), "payload": payload},
|
|
49
|
+
ensure_ascii=False), encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def litcheck(card_id: Optional[str] = None, address: Optional[str] = None,
|
|
53
|
+
developer: Optional[str] = None, since: int = 2020, file: Optional[str] = None,
|
|
54
|
+
db=None) -> dict:
|
|
55
|
+
# build query from card if file provided
|
|
56
|
+
query = address or developer or ""
|
|
57
|
+
if file and not query:
|
|
58
|
+
try:
|
|
59
|
+
import json as _j
|
|
60
|
+
raw = _j.loads(Path(file).read_text(encoding="utf-8"))
|
|
61
|
+
if isinstance(raw, list):
|
|
62
|
+
raw = raw[0]
|
|
63
|
+
if isinstance(raw, dict) and "cards" in raw:
|
|
64
|
+
raw = raw["cards"][0]
|
|
65
|
+
addr = (raw.get("building", {}) or {}).get("address_raw")
|
|
66
|
+
if addr:
|
|
67
|
+
query = addr
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
if not query:
|
|
71
|
+
query = card_id or ""
|
|
72
|
+
|
|
73
|
+
key = _cache_key(query)
|
|
74
|
+
cached = _cache_get(key)
|
|
75
|
+
if cached is not None:
|
|
76
|
+
cached["cache_hit"] = True
|
|
77
|
+
return cached
|
|
78
|
+
|
|
79
|
+
bin_path = _sud_bin()
|
|
80
|
+
# try subprocess
|
|
81
|
+
try:
|
|
82
|
+
proc = subprocess.run(
|
|
83
|
+
[bin_path, "--json", "kad", "--name", query],
|
|
84
|
+
capture_output=True, text=True, timeout=30,
|
|
85
|
+
)
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
result = {"status": "unavailable", "reason": "sud_bin_not_found", "count": None, "links": []}
|
|
88
|
+
_cache_set(key, result)
|
|
89
|
+
return result
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
result = {"status": "error", "reason": str(exc), "count": None, "links": []}
|
|
92
|
+
_cache_set(key, result)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
if proc.returncode != 0:
|
|
96
|
+
result = {"status": "unavailable", "reason": proc.stderr.strip() or "sud_error", "count": None, "links": []}
|
|
97
|
+
_cache_set(key, result)
|
|
98
|
+
return result
|
|
99
|
+
# parse
|
|
100
|
+
try:
|
|
101
|
+
data = json.loads(proc.stdout)
|
|
102
|
+
except Exception:
|
|
103
|
+
result = {"status": "error", "reason": "invalid_json", "count": None, "links": []}
|
|
104
|
+
_cache_set(key, result)
|
|
105
|
+
return result
|
|
106
|
+
count = data.get("count")
|
|
107
|
+
links = data.get("links", [])
|
|
108
|
+
result = {"status": "ok", "count": count, "links": links, "query": query, "cache_hit": False}
|
|
109
|
+
_cache_set(key, result)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def render_text(result: dict) -> str:
|
|
114
|
+
if result.get("status") != "ok":
|
|
115
|
+
return f"litcheck: {result.get('status')} — {result.get('reason')}"
|
|
116
|
+
return f"выявлены записи {result.get('count')} по запросу {result.get('query')}, проверь детали; " + ", ".join(result.get("links", []))
|