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/poi.py ADDED
@@ -0,0 +1,180 @@
1
+ """v0.5.1 (the task brief) POI accessibility."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import math
7
+ import os
8
+ import time
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Optional
12
+
13
+ from .models import ListingCard
14
+
15
+ _CACHE_DIR = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga"))) / "poi_cache"
16
+ _RATE_LIMIT_SECONDS = 5.0
17
+ _last_request: float = 0.0
18
+
19
+ CATEGORIES = {
20
+ "school": lambda t: t.get("amenity") == "school",
21
+ "kindergarten": lambda t: t.get("amenity") == "kindergarten",
22
+ "grocery": lambda t: t.get("shop") in {"supermarket", "convenience", "grocery"},
23
+ "park": lambda t: t.get("leisure") == "park",
24
+ "clinic": lambda t: t.get("amenity") in {"clinic", "doctor", "hospital"},
25
+ }
26
+
27
+ _OVERPASS_URLS = [ # v0.5.4: публичный зеркалируется; single-endpoint = SPOF (урок realtor 406/timeout)
28
+ "https://overpass-api.de/api/interpreter",
29
+ "https://overpass.kumi.systems/api/interpreter",
30
+ ]
31
+
32
+
33
+ def _haversine(lat1, lon1, lat2, lon2):
34
+ R = 6371000.0
35
+ phi1 = math.radians(lat1)
36
+ phi2 = math.radians(lat2)
37
+ dphi = math.radians(lat2 - lat1)
38
+ dl = math.radians(lon2 - lon1)
39
+ a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dl / 2) ** 2
40
+ return 2 * R * math.asin(math.sqrt(a))
41
+
42
+
43
+ def _cache_key(lat, lon, radius):
44
+ return hashlib.sha256(f"{lat:.6f}:{lon:.6f}:{radius}".encode()).hexdigest()
45
+
46
+
47
+ def _cache_path(key):
48
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
49
+ return _CACHE_DIR / f"{key}.json"
50
+
51
+
52
+ def _cache_get(key):
53
+ p = _cache_path(key)
54
+ if not p.exists():
55
+ return None
56
+ try:
57
+ data = json.loads(p.read_text(encoding="utf-8"))
58
+ except Exception:
59
+ return None
60
+ ts = data.get("_ts")
61
+ if ts:
62
+ dt = datetime.fromisoformat(ts)
63
+ if datetime.now(timezone.utc) - dt > timedelta(days=30):
64
+ return None
65
+ return data.get("payload")
66
+
67
+
68
+ def _cache_set(key, payload):
69
+ p = _cache_path(key)
70
+ p.write_text(json.dumps({"_ts": datetime.now(timezone.utc).isoformat(), "payload": payload},
71
+ ensure_ascii=False), encoding="utf-8")
72
+
73
+
74
+ def _rate_limit():
75
+ global _last_request
76
+ now = time.time()
77
+ wait = _RATE_LIMIT_SECONDS - (now - _last_request)
78
+ if wait > 0:
79
+ time.sleep(wait)
80
+ _last_request = time.time()
81
+
82
+
83
+ def _query_overpass(lat, lon, radius):
84
+ # rate limit + cache handled by caller; this does network
85
+ from .net_guard import guarded_get
86
+ q = (f"[out:json];"
87
+ f"(node(around:{radius},{lat},{lon})[amenity~\"school|kindergarten|clinic|doctor|hospital\"];"
88
+ f"node(around:{radius},{lat},{lon})[shop~\"supermarket|convenience|grocery\"];"
89
+ f"node(around:{radius},{lat},{lon})[leisure=park];);out;")
90
+ last_err = None
91
+ for base in _OVERPASS_URLS[:2]:
92
+ url = f"{base}?data={q}"
93
+ _rate_limit()
94
+ try:
95
+ resp = guarded_get(url, timeout=25.0)
96
+ resp.raise_for_status()
97
+ return resp.json()
98
+ except Exception as exc: # noqa: BLE001 — деградация видима вызывающему
99
+ last_err = exc
100
+ continue
101
+ raise type(last_err)(f"overpass_all_endpoints_failed: {last_err}")
102
+
103
+
104
+ def _classify(elements, lat, lon, radius_m):
105
+ # returns dict category -> list of (distance, mode)
106
+ result = {cat: [] for cat in CATEGORIES}
107
+ for el in elements or []:
108
+ if el.get("type") != "node":
109
+ continue
110
+ elat = el.get("lat")
111
+ elon = el.get("lon")
112
+ if elat is None or elon is None:
113
+ continue
114
+ tags = el.get("tags") or {}
115
+ dist = _haversine(lat, lon, elat, elon)
116
+ # try walking graph
117
+ mode = "по прямой"
118
+ try:
119
+ from . import graph as _g
120
+ r = _g.route(lat, lon, elat, elon)
121
+ if r is not None:
122
+ meters, provider = r
123
+ dist = meters
124
+ mode = "пешком"
125
+ except Exception:
126
+ pass
127
+ # v0.5.1 контроль качества: радиус запроса = фильтр. Школа в 13.9 км
128
+ # «пешком» при radius-min=15 — шум, а не инфраструктура.
129
+ if dist > radius_m:
130
+ continue
131
+ for cat, pred in CATEGORIES.items():
132
+ if pred(tags):
133
+ result[cat].append((dist, mode))
134
+ out = {}
135
+ for cat, items in result.items():
136
+ if not items:
137
+ out[cat] = {"count": 0, "nearest_m": None, "mode": None}
138
+ continue
139
+ nearest = min(items, key=lambda x: x[0])
140
+ out[cat] = {"count": len(items), "nearest_m": int(round(nearest[0])), "mode": nearest[1]}
141
+ return out
142
+
143
+
144
+ def poi_for_card(card: Any, radius_min: int = 15, offline: Optional[str] = None) -> dict:
145
+ from .models import ListingCard
146
+ c = card if isinstance(card, ListingCard) else ListingCard(**card) if isinstance(card, dict) else None
147
+ if c is None:
148
+ raise TypeError("unsupported card")
149
+ geo = c.geo
150
+ lat = geo.lat if geo else None
151
+ lon = geo.lon if geo else None
152
+ if lat is None or lon is None:
153
+ return {"status": "no_coordinates", "categories": {}}
154
+ radius = max(100, radius_min * 80) # ~80m/min
155
+ key = _cache_key(lat, lon, radius)
156
+ cached = _cache_get(key)
157
+ if cached is not None:
158
+ payload = cached
159
+ elif offline:
160
+ raw = json.loads(Path(offline).read_text(encoding="utf-8"))
161
+ payload = raw
162
+ else:
163
+ payload = _query_overpass(lat, lon, radius)
164
+ _cache_set(key, payload)
165
+ categories = _classify(payload.get("elements", []) if isinstance(payload, dict) else [], lat, lon, radius)
166
+ return {"status": "ok", "lat": lat, "lon": lon, "radius_m": radius, "categories": categories}
167
+
168
+
169
+ def render_text(result: dict) -> str:
170
+ if result.get("status") == "no_coordinates":
171
+ return "no_coordinates"
172
+ cats = result.get("categories", {})
173
+ lines = ["🏫 Инфраструктура (прямая доступность, кэш на 30 дней):"]
174
+ for cat, info in cats.items():
175
+ if info.get("count", 0) == 0:
176
+ lines.append(f"{cat}: нет данных")
177
+ else:
178
+ mode = info.get("mode") or "по прямой"
179
+ lines.append(f"{cat}: {info['count']} шт., ближайший {info['nearest_m']} м ({mode})")
180
+ return "\n".join(lines)
nedviga/price_guard.py ADDED
@@ -0,0 +1,73 @@
1
+ """P3-3 (2026-10-20): fail-closed для карточек без цены.
2
+
3
+ Живой дефект (ТЗ realtor): карточка без цены молча уезжала с price_total_rub=None.
4
+ Схема (status enum) контракта не меняет — карантин выражается отдельным
5
+ jsonl-журналом price_drift: «цена пропала» = событие, а не отсутствие поля.
6
+
7
+ Порядок для цены:
8
+ 1) structured fields / itemprop (cian_card),
9
+ 2) adaptive selectors (adaptive_selectors.extract_price_adaptive),
10
+ 3) title-fallback (этот модуль: «12 500 000 ₽»/«12500000 руб.» из заголовка),
11
+ 4) ничего не найдено -> None + запись в drift-журнал (карантин для приёмки).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ _TITLE_PRICE_RE = re.compile(
23
+ r"(?<![\d.])(\d{1,3}(?:[ \u00a0]\d{3})+|\d{6,12})[ \u00a0]*(?:₽|руб)",
24
+ re.IGNORECASE,
25
+ )
26
+
27
+
28
+ def price_from_title(title: Optional[str]) -> Optional[float]:
29
+ """Вытащить цену из заголовка, только если рядом стоит ₽/руб.
30
+
31
+ Голые числа (комнаты, этажи, «1 из 12») не считаются ценой — ложные
32
+ срабатывания дороже молчаливого None (тогда уж drift-событие)."""
33
+ if not title:
34
+ return None
35
+ m = _TITLE_PRICE_RE.search(title)
36
+ if not m:
37
+ return None
38
+ digits = m.group(1).replace(" ", "").replace("\u00a0", "")
39
+ try:
40
+ value = float(digits)
41
+ except ValueError:
42
+ return None
43
+ if value < 100_000: # квартир-ценников ниже нет; отсекает мусор вроде «1250 ₽/м2»
44
+ return None
45
+ return value
46
+
47
+
48
+ def drift_file() -> Path:
49
+ return Path(os.environ.get(
50
+ "NEDVIGA_DRIFT_FILE",
51
+ str(Path.home() / ".cache" / "nedviga-cli" / "price_drift.jsonl"),
52
+ ))
53
+
54
+
55
+ def record_price_drift(card_id: str, url: Optional[str],
56
+ title: Optional[str]) -> Path:
57
+ """Карантин-событие: цена пропала после всех фолбэков. Возвращает путь к журналу."""
58
+ path = drift_file()
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+ # v0.4.5 (review R15): drift-журнал в ~/.cache не должен нести заголовок и
61
+ # полный url (в title бывают ПДн) — только id и host источника.
62
+ from urllib.parse import urlsplit
63
+ entry = {
64
+ "ts": datetime.now(timezone.utc).isoformat(),
65
+ "card_id": card_id,
66
+ "url_host": urlsplit(url).netloc if url else None,
67
+ "event": "price_missing_quarantined",
68
+ }
69
+ with path.open("a", encoding="utf-8") as fh:
70
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
71
+ fh.flush()
72
+ os.fsync(fh.fileno())
73
+ return path
nedviga/profiles.py ADDED
@@ -0,0 +1,131 @@
1
+ """v0.4.6 (the task brief F-03): именованные профили поиска (client search profiles).
2
+
3
+ CLI: ``nedviga-cli search --profile parents [аргументы…]``
4
+
5
+ Семантика:
6
+ - профиль задаёт базовые значения фильтров (defaults);
7
+ - любой явно переданный CLI-аргумент переопределяет поле профиля;
8
+ - без ``--profile`` поведение не меняется (инвариант обратной совместимости);
9
+ - неизвестный профиль -> :class:`UnknownProfileError` (CLI: envelope
10
+ ``reason="unknown_profile"``, exit code 3).
11
+
12
+ Пресеты встроены в модуль; дополнительные/переопределённые профили могут
13
+ приходить из JSON-файла, путь которого задан переменной окружения
14
+ ``NEDVIGA_PROFILES_JSON`` (структура: ``{"<name>": {"<field>": value}}``).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ # Булевы флаги search: CLI-значение True трактуется как «задано явно».
25
+ BOOL_FIELDS = {"floor_not_first", "strict_mkad", "only_flat", "no_shares", "no_auction", "no_gas"}
26
+ # Значимые поля: CLI-значение «не None» трактуется как «задано явно».
27
+ VALUE_FIELDS = {
28
+ "rooms",
29
+ "price_min",
30
+ "price_max",
31
+ "kitchen_min",
32
+ "min_house_floors",
33
+ "geo_districts",
34
+ "metro_walk_max",
35
+ }
36
+ _ALLOWED_FIELDS = BOOL_FIELDS | VALUE_FIELDS
37
+
38
+ # Встроенные пресеты (ТЗ the task brief §3.1, автор agent-profile).
39
+ BUILTIN_PROFILES: dict[str, dict[str, Any]] = {
40
+ "parents": {
41
+ "rooms": 2,
42
+ "floor_not_first": True,
43
+ "min_house_floors": 10,
44
+ "geo_districts": ["ЮАО", "ЮЗАО"],
45
+ "kitchen_min": 7.5,
46
+ "price_max": 20_000_000,
47
+ "metro_walk_max": 20,
48
+ "strict_mkad": True,
49
+ "only_flat": True,
50
+ "no_shares": True,
51
+ "no_auction": True,
52
+ },
53
+ "lnfnt": {
54
+ "price_max": 16_000_000,
55
+ "floor_not_first": True,
56
+ "min_house_floors": 12,
57
+ "geo_districts": ["ЗАО", "ЮАО", "ЮВАО"],
58
+ "kitchen_min": 8.0,
59
+ "strict_mkad": True,
60
+ "only_flat": True,
61
+ "no_shares": True,
62
+ "no_auction": True,
63
+ },
64
+ }
65
+
66
+ _ENV_VAR = "NEDVIGA_PROFILES_JSON"
67
+
68
+
69
+ class UnknownProfileError(ValueError):
70
+ """Профиль с таким именем не найден (ни во встроенных, ни в файле)."""
71
+
72
+ def __init__(self, requested: str, known: list[str]) -> None:
73
+ super().__init__(f"unknown search profile '{requested}'")
74
+ self.requested = requested
75
+ self.known = list(known)
76
+
77
+
78
+ def load_profiles() -> dict[str, dict[str, Any]]:
79
+ """Встроенные профили + опциональный override-файл из NEDVIGA_PROFILES_JSON.
80
+
81
+ Некорректный/нечитаемый файл не роняет CLI: fallback на встроенные.
82
+ Поля, не входящие в известные фильтры search, из файла отбрасываются.
83
+ """
84
+ profiles: dict[str, dict[str, Any]] = {
85
+ name: dict(values) for name, values in BUILTIN_PROFILES.items()
86
+ }
87
+ path = os.environ.get(_ENV_VAR, "").strip()
88
+ if path:
89
+ try:
90
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
91
+ except (OSError, ValueError):
92
+ raw = None
93
+ if isinstance(raw, dict):
94
+ for name, values in raw.items():
95
+ if isinstance(name, str) and isinstance(values, dict):
96
+ merged = dict(profiles.get(name, {}))
97
+ merged.update(
98
+ {k: v for k, v in values.items() if k in _ALLOWED_FIELDS}
99
+ )
100
+ profiles[name] = merged
101
+ return profiles
102
+
103
+
104
+ def available_profiles() -> list[str]:
105
+ return sorted(load_profiles())
106
+
107
+
108
+ def get_profile(name: str) -> dict[str, Any]:
109
+ profiles = load_profiles()
110
+ if name not in profiles:
111
+ raise UnknownProfileError(name, available_profiles())
112
+ return profiles[name]
113
+
114
+
115
+ def apply_profile(args: Any, name: str) -> list[str]:
116
+ """Применить профиль к argparse-namespace; явные CLI-аргументы сильнее.
117
+
118
+ Возвращает список применённых полей (для metadata).
119
+ """
120
+ applied: list[str] = []
121
+ for field, value in get_profile(name).items():
122
+ if isinstance(value, list):
123
+ value = ",".join(str(item) for item in value)
124
+ current = getattr(args, field, None)
125
+ if field in BOOL_FIELDS and bool(current):
126
+ continue # флаг задан в CLI
127
+ if field in VALUE_FIELDS and current is not None:
128
+ continue # значение задано в CLI
129
+ setattr(args, field, value)
130
+ applied.append(field)
131
+ return applied
nedviga/projects.py ADDED
@@ -0,0 +1,112 @@
1
+ """v0.5.0 (the task brief) projects."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import sqlite3
7
+ import threading
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ _STATE = lambda: Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
13
+
14
+
15
+ def db_path() -> Path:
16
+ p = _STATE() / "projects.sqlite"
17
+ p.parent.mkdir(parents=True, exist_ok=True)
18
+ return p
19
+
20
+
21
+ def _connect(db=None):
22
+ p = db or db_path()
23
+ p.parent.mkdir(parents=True, exist_ok=True)
24
+ conn = sqlite3.connect(str(p), timeout=30.0)
25
+ conn.row_factory = sqlite3.Row
26
+ conn.execute("PRAGMA journal_mode=WAL")
27
+ conn.execute("""CREATE TABLE IF NOT EXISTS projects (
28
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, query_json TEXT, created TEXT NOT NULL)""")
29
+ conn.execute("""CREATE TABLE IF NOT EXISTS project_cards (
30
+ project_id TEXT NOT NULL, card_id TEXT NOT NULL, added_at TEXT NOT NULL,
31
+ UNIQUE(project_id, card_id))""")
32
+ conn.commit()
33
+ return conn
34
+
35
+
36
+ def project_new(name: str, query: Optional[str] = None, db=None) -> dict:
37
+ pid = name
38
+ conn = _connect(db)
39
+ try:
40
+ conn.execute("INSERT OR REPLACE INTO projects (id,name,query_json,created) VALUES (?,?,?,?)",
41
+ (pid, name, query, datetime.now(timezone.utc).isoformat()))
42
+ conn.commit()
43
+ return {"ok": True, "id": pid, "name": name}
44
+ finally:
45
+ conn.close()
46
+
47
+
48
+ def project_list(db=None) -> list[dict]:
49
+ conn = _connect(db)
50
+ try:
51
+ rows = conn.execute("SELECT * FROM projects ORDER BY name").fetchall()
52
+ return [dict(r) for r in rows]
53
+ finally:
54
+ conn.close()
55
+
56
+
57
+ def project_add(project: str, card_id: str, db=None) -> dict:
58
+ conn = _connect(db)
59
+ try:
60
+ # idempotent
61
+ exists = conn.execute("SELECT 1 FROM project_cards WHERE project_id=? AND card_id=?",
62
+ (project, card_id)).fetchone()
63
+ if exists:
64
+ return {"ok": True, "project": project, "card_id": card_id, "idempotent": True}
65
+ conn.execute("INSERT INTO project_cards (project_id, card_id, added_at) VALUES (?,?,?)",
66
+ (project, card_id, datetime.now(timezone.utc).isoformat()))
67
+ conn.commit()
68
+ return {"ok": True, "project": project, "card_id": card_id, "added": True}
69
+ finally:
70
+ conn.close()
71
+
72
+
73
+ def project_cards(project: str, db=None) -> list[str]:
74
+ conn = _connect(db)
75
+ try:
76
+ rows = conn.execute("SELECT card_id FROM project_cards WHERE project_id=? ORDER BY added_at",
77
+ (project,)).fetchall()
78
+ return [r["card_id"] for r in rows]
79
+ finally:
80
+ conn.close()
81
+
82
+
83
+ def project_open(project: str, db=None) -> dict:
84
+ cards = project_cards(project, db=db)
85
+ # skeletons
86
+ return {"project": project, "cards": [{"id": c} for c in cards]}
87
+
88
+
89
+ def project_refresh(project: str, file: str, db=None) -> dict:
90
+ # new search export
91
+ raw = json.loads(Path(file).read_text(encoding="utf-8"))
92
+ if isinstance(raw, dict):
93
+ if "cards" in raw and isinstance(raw["cards"], list):
94
+ raw = raw["cards"]
95
+ elif "data" in raw:
96
+ d = raw["data"]
97
+ raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
98
+ else:
99
+ raw = [raw]
100
+ if not isinstance(raw, list):
101
+ raw = [raw]
102
+ new_ids = {str(x.get("id")) for x in raw if isinstance(x, dict) and x.get("id")}
103
+ old_ids = set(project_cards(project, db=db))
104
+ added = sorted(new_ids - old_ids)
105
+ removed = sorted(old_ids - new_ids)
106
+ # cheaper: compare price if card present in both? Need old snapshot not stored; skip
107
+ cheaper = []
108
+ # add new ids to project (idempotent)
109
+ for cid in added:
110
+ project_add(project, cid, db=db)
111
+ return {"project": project, "new": added, "removed": removed, "cheaper": cheaper,
112
+ "delta": {"added": len(added), "removed": len(removed)}}
nedviga/purge.py ADDED
@@ -0,0 +1,116 @@
1
+ """v0.5.5 (the task brief) purge."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from . import pipeline
10
+ from .auditlog import log_action
11
+
12
+
13
+ def _state_dir() -> Path:
14
+ return Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
15
+
16
+
17
+ def purge_card(card_id: str, yes: bool = False, dry: bool = True) -> dict:
18
+ # dry run: list tables and counts
19
+ conn = pipeline._connect()
20
+ tables = ["pipeline_items", "status_history", "watch_snapshots", "watch_history",
21
+ "show_feedback", "deal_structures", "project_cards", "chain_links"]
22
+ counts = {}
23
+ for t in tables:
24
+ try:
25
+ if t == "project_cards":
26
+ c = conn.execute(f"SELECT COUNT(*) FROM {t} WHERE card_id=?", (card_id,)).fetchone()[0]
27
+ elif t in ("status_history", "watch_history"):
28
+ c = conn.execute(f"SELECT COUNT(*) FROM {t} WHERE card_id=?", (card_id,)).fetchone()[0]
29
+ elif t == "chain_links":
30
+ c = conn.execute(f"SELECT COUNT(*) FROM {t} WHERE a=? OR b=?", (card_id, card_id)).fetchone()[0]
31
+ else:
32
+ c = conn.execute(f"SELECT COUNT(*) FROM {t} WHERE card_id=?", (card_id,)).fetchone()[0]
33
+ counts[t] = c
34
+ except Exception:
35
+ counts[t] = 0
36
+ conn.close()
37
+
38
+ total = sum(counts.values())
39
+ if dry or not yes:
40
+ return {"dry": True, "card_id": card_id, "tables": counts, "total": total,
41
+ "note": "files on disk not removed; manual decision required"}
42
+
43
+ # execute
44
+ conn = pipeline._connect()
45
+ # ensure tables exist
46
+ try:
47
+ from .chains import _connect as _cc
48
+ _cc().close()
49
+ except Exception:
50
+ pass
51
+ try:
52
+ from .feedback import _connect as _fc
53
+ _fc().close()
54
+ except Exception:
55
+ pass
56
+ for sql, params in [
57
+ ("DELETE FROM pipeline_items WHERE card_id=?", (card_id,)),
58
+ ("DELETE FROM status_history WHERE card_id=?", (card_id,)),
59
+ ("DELETE FROM watch_snapshots WHERE card_id=?", (card_id,)),
60
+ ("DELETE FROM watch_history WHERE card_id=?", (card_id,)),
61
+ ("DELETE FROM show_feedback WHERE card_id=?", (card_id,)),
62
+ ("DELETE FROM deal_structures WHERE card_id=?", (card_id,)),
63
+ ("DELETE FROM chain_links WHERE a=? OR b=?", (card_id, card_id)),
64
+ ("DELETE FROM project_cards WHERE card_id=?", (card_id,)),
65
+ ]:
66
+ try:
67
+ conn.execute(sql, params)
68
+ except Exception:
69
+ pass
70
+ try:
71
+ from .textsearch import db_path as tdb
72
+ import sqlite3
73
+ tconn = sqlite3.connect(str(tdb()))
74
+ try:
75
+ tconn.execute("DELETE FROM documents WHERE card_id=?", (card_id,))
76
+ tconn.execute("DELETE FROM docs_fts WHERE card_id=?", (card_id,))
77
+ tconn.commit()
78
+ except Exception:
79
+ pass
80
+ tconn.close()
81
+ except Exception:
82
+ pass
83
+ try:
84
+ conn.commit()
85
+ except Exception:
86
+ pass
87
+ conn.close()
88
+
89
+ # idempotency: if total was 0 -> not_found
90
+ if total == 0:
91
+ return {"ok": True, "card_id": card_id, "deleted": 0, "reason": "not_found",
92
+ "tables": counts}
93
+ # audit trail
94
+ try:
95
+ log_action(role=os.environ.get("NEDVIGA_ROLE", "agent"), command="purge",
96
+ card_id=card_id, rc=0, reason="purge_card",
97
+ argv=[], pii=False)
98
+ except Exception:
99
+ pass
100
+ return {"ok": True, "card_id": card_id, "deleted": total, "tables": counts,
101
+ "audit": "recorded"}
102
+
103
+
104
+ def purge_profile(name: str, yes: bool = False, dry: bool = True) -> dict:
105
+ path = _state_dir() / "profile_overrides.json"
106
+ if dry or not yes:
107
+ return {"dry": True, "profile": name, "path": str(path)}
108
+ # remove overrides file if exists
109
+ if path.exists():
110
+ path.unlink()
111
+ try:
112
+ log_action(role=os.environ.get("NEDVIGA_ROLE", "agent"), command="purge",
113
+ card_id=None, rc=0, reason="purge_profile", argv=[], pii=False)
114
+ except Exception:
115
+ pass
116
+ return {"ok": True, "profile": name, "deleted": 1}
nedviga/py.typed ADDED
File without changes