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/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()
|