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/market.py ADDED
@@ -0,0 +1,102 @@
1
+ """v0.5.3 (the task brief) market analytics."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from .bargain import _load_cards, _ppm2, _percentile
10
+ from . import pipeline
11
+
12
+
13
+ def _exposure_days(card_id: str) -> Optional[int]:
14
+ try:
15
+ conn = pipeline._connect()
16
+ row = conn.execute("SELECT MIN(snapshot_at) as first_seen FROM watch_snapshots WHERE card_id=?", (card_id,)).fetchone()
17
+ conn.close()
18
+ except Exception:
19
+ return None
20
+ if not row or not row["first_seen"]:
21
+ return None
22
+ try:
23
+ dt = datetime.fromisoformat(str(row["first_seen"]).replace("Z", "+00:00"))
24
+ if dt.tzinfo is None:
25
+ dt = dt.replace(tzinfo=timezone.utc)
26
+ return (datetime.now(timezone.utc) - dt).days
27
+ except ValueError:
28
+ return None
29
+
30
+
31
+ def _complex_name(card):
32
+ from .bargain import _complex_name
33
+ return _complex_name(card)
34
+
35
+
36
+ def analyze(corpus_paths, district=None, okrug=None, days=30, group_by="district") -> dict[str, Any]:
37
+ cards = []
38
+ for p in corpus_paths:
39
+ cards.extend(_load_cards(p))
40
+ if district:
41
+ cards = [c for c in cards if (c.geo.district if c.geo else None) == district]
42
+ if okrug:
43
+ cards = [c for c in cards if (c.geo.area_name if c.geo else None) == okrug]
44
+
45
+ prices = [ppm for ppm in (_ppm2(c) for c in cards) if ppm is not None]
46
+ n = len(prices)
47
+ if n < 20:
48
+ return {"ok": False, "reason": "insufficient_sample", "n": n, "required": 20,
49
+ "district": district, "okrug": okrug, "group_basis": "по группе (n={})".format(n)}
50
+
51
+ s = sorted(prices)
52
+ median = _percentile(s, 0.5)
53
+ p25 = _percentile(s, 0.25)
54
+ p75 = _percentile(s, 0.75)
55
+
56
+ # rooms distribution
57
+ rooms_dist: dict[str, int] = {}
58
+ for c in cards:
59
+ r = c.parameters.rooms
60
+ key = str(r) if r is not None else "unknown"
61
+ rooms_dist[key] = rooms_dist.get(key, 0) + 1
62
+
63
+ # exposure
64
+ exposures = []
65
+ for c in cards:
66
+ d = _exposure_days(c.id)
67
+ if d is not None:
68
+ exposures.append(d)
69
+ if exposures:
70
+ exposure = {"min": min(exposures), "max": max(exposures), "median": _percentile(sorted(exposures), 0.5),
71
+ "n": len(exposures)}
72
+ else:
73
+ exposure = {"status": "insufficient"}
74
+
75
+ return {
76
+ "ok": True,
77
+ "n": n,
78
+ "district": district,
79
+ "group_basis": "по {} (n={})".format("ЖК" if group_by == "complex" else (district or "району"), n),
80
+ "okrug": okrug,
81
+ "median": median,
82
+ "p25": p25,
83
+ "p75": p75,
84
+ "rooms_distribution": rooms_dist,
85
+ "exposure": exposure,
86
+ }
87
+
88
+
89
+ def render_text(result: dict, fmt: str = "telegram") -> str:
90
+ if not result.get("ok"):
91
+ return f"insufficient_sample: n={result.get('n')}"
92
+ lines = []
93
+ lines.append(f"Рынок: n={result['n']}")
94
+ lines.append(f"медиана ₽/м²: {result['median']:,.0f}".replace(",", " "))
95
+ lines.append(f"P25: {result['p25']:,.0f} P75: {result['p75']:,.0f}".replace(",", " "))
96
+ lines.append("комнаты: " + ", ".join(f"{k}:{v}" for k, v in result["rooms_distribution"].items()))
97
+ exp = result["exposure"]
98
+ if isinstance(exp, dict) and exp.get("status") == "insufficient":
99
+ lines.append("экспозиция: insufficient")
100
+ else:
101
+ lines.append(f"экспозиция: {exp['min']}-{exp['max']} дн (медиана {exp['median']:.0f}, n={exp['n']})")
102
+ return "\n".join(lines)
nedviga/mcp_server.py ADDED
@@ -0,0 +1,195 @@
1
+ """v0.5.7 (the task brief) MCP server."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import io
6
+ import json
7
+ import os
8
+ import sys
9
+ from typing import Any, Optional
10
+
11
+ import nedviga.cli as _cli
12
+
13
+
14
+ TOOLS = [ {"name": "getSourceCodeUrl", "description": "AGPL-3.0-only source information: license, repository URL, how to obtain sources", "inputSchema": {"type": "object"}},
15
+
16
+ {"name": "card", "description": "Family card generation inputs"},
17
+ {"name": "compare", "description": "Compare listings"},
18
+ {"name": "pipeline_list", "description": "List pipeline"},
19
+ {"name": "pipeline_set", "description": "Set pipeline status"},
20
+ {"name": "watch_check", "description": "Watch check"},
21
+ {"name": "digest", "description": "Digest"},
22
+ {"name": "market", "description": "Market analytics"},
23
+ {"name": "bargain", "description": "Bargain analysis"},
24
+ {"name": "dedup", "description": "Dedup listings"},
25
+ {"name": "risk", "description": "Risk scoring"},
26
+ {"name": "triage", "description": "Legal triage"},
27
+ {"name": "checklist", "description": "Checklist"},
28
+ {"name": "scorefit", "description": "Score fit"},
29
+ {"name": "tour", "description": "Tour routing"},
30
+ {"name": "poi_offline", "description": "POI offline"},
31
+ {"name": "litcheck", "description": "Litigation pre-check"},
32
+ {"name": "ops", "description": "Ops panel"},
33
+ {"name": "search_offline", "description": "Offline search corpus"},
34
+ {"name": "auto", "description": "Auto selection"},
35
+ {"name": "eco", "description": "Eco proxy"},
36
+ {"name": "stale", "description": "Stale detection"},
37
+ ]
38
+
39
+
40
+ def tool_list():
41
+ return {"tools": [{"name": t["name"], "description": t["description"], "inputSchema": {"type": "object"}} for t in TOOLS]}
42
+
43
+
44
+ def _cmd_for(name: str):
45
+ mapping = {
46
+ "card": _cli._cmd_card,
47
+ "compare": _cli._cmd_compare,
48
+ "pipeline_list": _cli._cmd_pipeline,
49
+ "pipeline_set": _cli._cmd_pipeline,
50
+ "watch_check": _cli._cmd_watch,
51
+ "digest": _cli._cmd_digest,
52
+ "market": _cli._cmd_market,
53
+ "bargain": _cli._cmd_bargain,
54
+ "dedup": _cli._cmd_dedup,
55
+ "risk": _cli._cmd_risk,
56
+ "triage": _cli._cmd_triage,
57
+ "checklist": _cli._cmd_checklist,
58
+ "scorefit": _cli._cmd_scorefit,
59
+ "tour": _cli._cmd_tour,
60
+ "poi_offline": _cli._cmd_poi,
61
+ "litcheck": _cli._cmd_litcheck,
62
+ "ops": _cli._cmd_ops if hasattr(_cli, "_cmd_ops") else None,
63
+ "auto": _cli._cmd_auto,
64
+ "eco": _cli._cmd_eco,
65
+ "stale": _cli._cmd_stale,
66
+ "search_offline": _cli._cmd_search,
67
+ }
68
+ return mapping.get(name)
69
+
70
+
71
+ def _prepare_args(name: str, arguments: dict):
72
+ ns = argparse.Namespace()
73
+ ns.with_contacts = False
74
+ ns._contacts_requested = False
75
+ ns.role = "agent"
76
+ ns.purpose = None
77
+ # defaults for common
78
+ for k, v in (arguments or {}).items():
79
+ if k == "with_contacts":
80
+ continue
81
+ setattr(ns, k, v)
82
+ # command-specific defaults
83
+ if name == "pipeline_list":
84
+ ns.pipeline_action = "list"
85
+ ns.command = "pipeline"
86
+ elif name == "pipeline_set":
87
+ ns.pipeline_action = "set"
88
+ ns.command = "pipeline"
89
+ elif name == "watch_check":
90
+ ns.watch_action = "check"
91
+ ns.command = "watch"
92
+ elif name == "checklist":
93
+ # default new? leave action if provided
94
+ if not hasattr(ns, "checklist_action"):
95
+ ns.checklist_action = "new"
96
+ ns.command = "checklist"
97
+ elif name == "poi_offline":
98
+ ns.command = "poi"
99
+ else:
100
+ ns.command = name.replace("_", "-")
101
+ # ensure format defaults
102
+ if not hasattr(ns, "format"):
103
+ ns.format = "md" if name in {"compare", "tour"} else "telegram"
104
+ if not hasattr(ns, "out"):
105
+ ns.out = None
106
+ if not hasattr(ns, "output"):
107
+ ns.output = None
108
+ return ns
109
+
110
+
111
+ def _invoke(name: str, arguments: dict) -> str:
112
+
113
+ if name == "getSourceCodeUrl":
114
+ import json as _j
115
+ from ._wm import source_info
116
+ try:
117
+ from . import __version__ as _ver
118
+ except Exception:
119
+ _ver = "0"
120
+ return _j.dumps(source_info(_ver), ensure_ascii=False, indent=1)
121
+ func = _cmd_for(name)
122
+ if func is None:
123
+ raise ValueError(f"unknown tool: {name}")
124
+ ns = _prepare_args(name, arguments)
125
+ # map path->file for some tools
126
+ if hasattr(ns, "path") and not hasattr(ns, "file"):
127
+ pass
128
+ # common: if arguments has path, set file too for tools expecting file
129
+ if "path" in (arguments or {}) and not getattr(ns, "file", None):
130
+ try:
131
+ ns.file = arguments["path"]
132
+ except Exception:
133
+ pass
134
+ buf = io.StringIO()
135
+ old = sys.stdout
136
+ sys.stdout = buf
137
+ try:
138
+ func(ns)
139
+ except Exception as exc:
140
+ sys.stdout = old
141
+ return json.dumps({"error": str(exc)})
142
+ finally:
143
+ sys.stdout = old
144
+ text = buf.getvalue().strip()
145
+ # audit
146
+ try:
147
+ from .auditlog import log_action
148
+ log_action(role="agent", command=f"mcp:{name}", card_id=getattr(ns, "card_id", None) or getattr(ns, "target", None),
149
+ rc=0, reason=None, argv=[], pii=False)
150
+ except Exception:
151
+ pass
152
+ return text
153
+
154
+
155
+ def handle_request(request):
156
+ method = request.get("method")
157
+ rid = request.get("id")
158
+ if method == "initialize":
159
+ result = {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},
160
+ "serverInfo": {"name": "nedviga-cli", "version": __import__("nedviga").__version__}}
161
+ elif method == "notifications/initialized":
162
+ return None
163
+ elif method == "tools/list":
164
+ result = tool_list()
165
+ elif method == "tools/call":
166
+ try:
167
+ params = request.get("params", {}) or {}
168
+ name = params["name"]
169
+ args = params.get("arguments", {}) or {}
170
+ text = _invoke(name, args)
171
+ result = {"content": [{"type": "text", "text": text}], "isError": False}
172
+ except (KeyError, TypeError, ValueError, OSError) as exc:
173
+ result = {"content": [{"type": "text", "text": str(exc)}], "isError": True}
174
+ except Exception as exc:
175
+ result = {"content": [{"type": "text", "text": f"unexpected: {type(exc).__name__}: {exc}"}], "isError": True}
176
+ else:
177
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "Method not found"}}
178
+ return {"jsonrpc": "2.0", "id": rid, "result": result}
179
+
180
+
181
+ def serve_stdio(input_stream=None, output_stream=None):
182
+ input_stream = input_stream or sys.stdin
183
+ output_stream = output_stream or sys.stdout
184
+ for line in input_stream:
185
+ if not line.strip():
186
+ continue
187
+ try:
188
+ frame = json.loads(line)
189
+ except json.JSONDecodeError as exc:
190
+ response = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"Parse error: {exc}"}}
191
+ else:
192
+ response = handle_request(frame)
193
+ if response is not None:
194
+ output_stream.write(json.dumps(response, ensure_ascii=False) + "\n")
195
+ output_stream.flush()