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
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
@@ -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", []))