windguru 0.2.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.
- guru/__init__.py +28 -0
- guru/cli/__init__.py +3 -0
- guru/cli/console.py +3 -0
- guru/cli/errors.py +34 -0
- guru/cli/main.py +209 -0
- guru/cli/render.py +88 -0
- guru/core/__init__.py +22 -0
- guru/core/envelope.py +45 -0
- guru/core/errors.py +60 -0
- guru/core/instruct.py +66 -0
- guru/mcp/__init__.py +1 -0
- guru/mcp/_entry.py +31 -0
- guru/mcp/server.py +111 -0
- guru/models/__init__.py +4 -0
- guru/models/aliases.py +54 -0
- guru/models/blend.py +28 -0
- guru/models/forecast.py +62 -0
- guru/models/models.py +5 -0
- guru/search/__init__.py +13 -0
- guru/search/blend.py +101 -0
- guru/search/blend_math.py +128 -0
- guru/search/client.py +62 -0
- guru/search/exceptions.py +34 -0
- guru/search/forecast.py +106 -0
- guru/search/near.py +94 -0
- guru/search/spots.py +102 -0
- windguru-0.2.0.dist-info/METADATA +176 -0
- windguru-0.2.0.dist-info/RECORD +31 -0
- windguru-0.2.0.dist-info/WHEEL +4 -0
- windguru-0.2.0.dist-info/entry_points.txt +4 -0
- windguru-0.2.0.dist-info/licenses/LICENSE +21 -0
guru/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from guru.models.blend import BestForecast, BlendWeight
|
|
4
|
+
from guru.models.forecast import Forecast, ForecastHour, Spot
|
|
5
|
+
from guru.search.blend import get_best_forecast
|
|
6
|
+
from guru.search.forecast import get_forecast
|
|
7
|
+
from guru.search.near import spots_near
|
|
8
|
+
from guru.search.spots import get_spot, resolve_spot, search_spots
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("windguru")
|
|
12
|
+
except PackageNotFoundError: # pragma: no cover
|
|
13
|
+
__version__ = "0.2.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BestForecast",
|
|
17
|
+
"BlendWeight",
|
|
18
|
+
"Forecast",
|
|
19
|
+
"ForecastHour",
|
|
20
|
+
"Spot",
|
|
21
|
+
"get_best_forecast",
|
|
22
|
+
"get_forecast",
|
|
23
|
+
"get_spot",
|
|
24
|
+
"resolve_spot",
|
|
25
|
+
"search_spots",
|
|
26
|
+
"spots_near",
|
|
27
|
+
"__version__",
|
|
28
|
+
]
|
guru/cli/__init__.py
ADDED
guru/cli/console.py
ADDED
guru/cli/errors.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""CLI emit / fail helpers (stdout JSON + Rich)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from guru.cli.console import console
|
|
12
|
+
from guru.core.envelope import error_payload, success_payload
|
|
13
|
+
from guru.core.errors import classify_error
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def emit_json(data: Any) -> None:
|
|
17
|
+
"""Write JSON to stdout (success or pre-built envelope)."""
|
|
18
|
+
sys.stdout.write(json.dumps(data, default=str) + "\n")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def print_ok(data: Any) -> None:
|
|
22
|
+
"""Emit a success envelope."""
|
|
23
|
+
emit_json(success_payload(data))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fail(exc: BaseException, *, as_json: bool) -> None:
|
|
27
|
+
"""Print error (JSON or Rich) and exit with a stable code."""
|
|
28
|
+
classified = classify_error(exc)
|
|
29
|
+
code = 2 if classified.retryable else 1
|
|
30
|
+
if as_json:
|
|
31
|
+
emit_json(error_payload(exc))
|
|
32
|
+
raise typer.Exit(code) from exc
|
|
33
|
+
console.print(f"[red]{exc}[/red]")
|
|
34
|
+
raise typer.Exit(code) from exc
|
guru/cli/main.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Typer CLI — agent-friendly Windguru client (fli-style)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from guru import __version__
|
|
10
|
+
from guru.cli.console import console
|
|
11
|
+
from guru.cli.errors import fail, print_ok
|
|
12
|
+
from guru.cli.render import (
|
|
13
|
+
print_best,
|
|
14
|
+
print_forecast,
|
|
15
|
+
print_models_table,
|
|
16
|
+
print_near_table,
|
|
17
|
+
print_spots_table,
|
|
18
|
+
)
|
|
19
|
+
from guru.core.envelope import ERROR_SCHEMA, dump_model
|
|
20
|
+
from guru.core.instruct import instruct_payload
|
|
21
|
+
from guru.models.aliases import list_models
|
|
22
|
+
from guru.search.blend import get_best_forecast
|
|
23
|
+
from guru.search.exceptions import GuruError
|
|
24
|
+
from guru.search.forecast import get_forecast
|
|
25
|
+
from guru.search.near import spots_near
|
|
26
|
+
from guru.search.spots import resolve_spot, search_spots
|
|
27
|
+
|
|
28
|
+
_CATCH = (GuruError, ValueError, OSError)
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="guru",
|
|
32
|
+
help=(
|
|
33
|
+
"Windguru CLI for humans and AI agents. "
|
|
34
|
+
"Prefer `guru best <spot> --json` (WINDGURU_DEFAULT top 3). "
|
|
35
|
+
"Run `guru instruct --json` for the agent recipe."
|
|
36
|
+
),
|
|
37
|
+
no_args_is_help=True,
|
|
38
|
+
add_completion=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("version")
|
|
43
|
+
def version_cmd(
|
|
44
|
+
as_json: bool = typer.Option(False, "--json", help="Machine-readable envelope"),
|
|
45
|
+
) -> None:
|
|
46
|
+
if as_json:
|
|
47
|
+
print_ok({"version": __version__})
|
|
48
|
+
else:
|
|
49
|
+
console.print(__version__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("instruct")
|
|
53
|
+
def instruct_cmd(
|
|
54
|
+
as_json: bool = typer.Option(True, "--json/--no-json", help="JSON recipe (default on)"),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Explain the free WINDGURU_DEFAULT → top-3 workflow for agents."""
|
|
57
|
+
payload = instruct_payload()
|
|
58
|
+
if as_json:
|
|
59
|
+
print_ok(payload)
|
|
60
|
+
return
|
|
61
|
+
console.print(payload["summary"])
|
|
62
|
+
for step in payload["steps"]:
|
|
63
|
+
console.print(f"{step['step']}. {step['action']}: {step['detail']}")
|
|
64
|
+
if step.get("command"):
|
|
65
|
+
console.print(f" [cyan]{step['command']}[/cyan]")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("spots")
|
|
69
|
+
def spots_cmd(
|
|
70
|
+
query: str = typer.Argument(..., help="Spot name search"),
|
|
71
|
+
limit: int = typer.Option(20, "--limit", "-n"),
|
|
72
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Search Windguru spots by name (live API)."""
|
|
75
|
+
try:
|
|
76
|
+
spots = search_spots(query, limit=limit)
|
|
77
|
+
except _CATCH as exc:
|
|
78
|
+
fail(exc, as_json=as_json)
|
|
79
|
+
if as_json:
|
|
80
|
+
print_ok([dump_model(s) for s in spots])
|
|
81
|
+
return
|
|
82
|
+
print_spots_table(spots, title=f"Spots matching {query!r}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command("near")
|
|
86
|
+
def near_cmd(
|
|
87
|
+
lat: float = typer.Option(..., "--lat", help="Latitude"),
|
|
88
|
+
lon: float = typer.Option(..., "--lon", help="Longitude"),
|
|
89
|
+
radius: float = typer.Option(50.0, "--radius", help="Search radius km"),
|
|
90
|
+
limit: int = typer.Option(20, "--limit", "-n"),
|
|
91
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Named spots near a point (free map markers — not PRO click-forecast)."""
|
|
94
|
+
try:
|
|
95
|
+
spots = spots_near(lat, lon, radius_km=radius, limit=limit)
|
|
96
|
+
except _CATCH as exc:
|
|
97
|
+
fail(exc, as_json=as_json)
|
|
98
|
+
if as_json:
|
|
99
|
+
print_ok([dump_model(s) for s in spots])
|
|
100
|
+
return
|
|
101
|
+
print_near_table(spots, lat=lat, lon=lon, radius=radius)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("best")
|
|
105
|
+
def best_cmd(
|
|
106
|
+
spot: str = typer.Argument(..., help="Spot id or unique name"),
|
|
107
|
+
top: int = typer.Option(3, "--top", help="How many WINDGURU_DEFAULT models to keep"),
|
|
108
|
+
hours: int = typer.Option(
|
|
109
|
+
48, "--hours", "-H", help="Max forecast steps to return per model"
|
|
110
|
+
),
|
|
111
|
+
pick: int | None = typer.Option(None, "--pick", help="Disambiguate by spot id"),
|
|
112
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
113
|
+
) -> None:
|
|
114
|
+
"""WINDGURU DEFAULT Tune → top models by weight → those forecasts."""
|
|
115
|
+
try:
|
|
116
|
+
resolved = resolve_spot(spot, pick=pick)
|
|
117
|
+
best = get_best_forecast(resolved.id, top=top, hours=hours)
|
|
118
|
+
except _CATCH as exc:
|
|
119
|
+
fail(exc, as_json=as_json)
|
|
120
|
+
if as_json:
|
|
121
|
+
print_ok(dump_model(best))
|
|
122
|
+
return
|
|
123
|
+
print_best(best)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command("forecast")
|
|
127
|
+
def forecast_cmd(
|
|
128
|
+
spot: str = typer.Argument(..., help="Spot id or unique name"),
|
|
129
|
+
model: str = typer.Option("gfs", "--model", "-m", help="Model alias or id"),
|
|
130
|
+
hours: int = typer.Option(
|
|
131
|
+
48, "--hours", "-H", help="Max forecast steps to return"
|
|
132
|
+
),
|
|
133
|
+
pick: int | None = typer.Option(None, "--pick", help="Disambiguate by spot id"),
|
|
134
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Single-model forecast (escape hatch; prefer `guru best`)."""
|
|
137
|
+
try:
|
|
138
|
+
resolved = resolve_spot(spot, pick=pick)
|
|
139
|
+
fc = get_forecast(resolved.id, model=model, hours=hours)
|
|
140
|
+
except _CATCH as exc:
|
|
141
|
+
fail(exc, as_json=as_json)
|
|
142
|
+
if as_json:
|
|
143
|
+
print_ok(dump_model(fc))
|
|
144
|
+
return
|
|
145
|
+
print_forecast(fc)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command("models")
|
|
149
|
+
def models_cmd(
|
|
150
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
151
|
+
) -> None:
|
|
152
|
+
"""List known model aliases (extend from live captures)."""
|
|
153
|
+
rows = list_models()
|
|
154
|
+
if as_json:
|
|
155
|
+
print_ok(rows)
|
|
156
|
+
return
|
|
157
|
+
print_models_table(rows) # type: ignore[arg-type]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command("schema")
|
|
161
|
+
def schema_cmd(
|
|
162
|
+
name: str = typer.Argument("forecast", help="spot | forecast | best | error | instruct"),
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Dump JSON Schema for agent payloads."""
|
|
165
|
+
from guru.models.blend import BestForecast
|
|
166
|
+
from guru.models.forecast import Forecast, Spot
|
|
167
|
+
|
|
168
|
+
schemas = {
|
|
169
|
+
"spot": Spot.model_json_schema(),
|
|
170
|
+
"forecast": Forecast.model_json_schema(),
|
|
171
|
+
"best": BestForecast.model_json_schema(),
|
|
172
|
+
"instruct": {"type": "object", "description": "see guru instruct --json"},
|
|
173
|
+
"error": ERROR_SCHEMA,
|
|
174
|
+
}
|
|
175
|
+
key = name.lower().strip()
|
|
176
|
+
if key not in schemas:
|
|
177
|
+
fail(
|
|
178
|
+
ValueError(f"Unknown schema {name!r}. Choose: {', '.join(sorted(schemas))}"),
|
|
179
|
+
as_json=True,
|
|
180
|
+
)
|
|
181
|
+
print_ok({"name": key, "schema": schemas[key]})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command("doctor")
|
|
185
|
+
def doctor_cmd(
|
|
186
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Env + version sanity check (no live Windguru call)."""
|
|
189
|
+
info = {
|
|
190
|
+
"version": __version__,
|
|
191
|
+
"GURU_TIMEOUT": os.environ.get("GURU_TIMEOUT", "30"),
|
|
192
|
+
"GURU_IMPERSONATE": os.environ.get("GURU_IMPERSONATE", "chrome"),
|
|
193
|
+
"models": list_models(),
|
|
194
|
+
}
|
|
195
|
+
if as_json:
|
|
196
|
+
print_ok(info)
|
|
197
|
+
return
|
|
198
|
+
console.print(f"guru {__version__}")
|
|
199
|
+
console.print(f"GURU_TIMEOUT={info['GURU_TIMEOUT']}")
|
|
200
|
+
console.print(f"GURU_IMPERSONATE={info['GURU_IMPERSONATE']}")
|
|
201
|
+
console.print(f"{len(info['models'])} known model aliases")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cli() -> None:
|
|
205
|
+
app()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
cli()
|
guru/cli/render.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Rich human-readable tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from guru.cli.console import console
|
|
8
|
+
from guru.models.blend import BestForecast
|
|
9
|
+
from guru.models.forecast import Forecast, Spot, wind_dir_cardinal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def print_spots_table(spots: list[Spot], *, title: str) -> None:
|
|
13
|
+
table = Table(title=title)
|
|
14
|
+
table.add_column("ID", style="cyan")
|
|
15
|
+
table.add_column("Name")
|
|
16
|
+
table.add_column("Country")
|
|
17
|
+
table.add_column("Nick")
|
|
18
|
+
for s in spots:
|
|
19
|
+
table.add_row(str(s.id), s.name, s.country or "", s.nickname or "")
|
|
20
|
+
console.print(table)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def print_near_table(spots: list[Spot], *, lat: float, lon: float, radius: float) -> None:
|
|
24
|
+
table = Table(title=f"Spots within {radius:g} km of {lat:g},{lon:g}")
|
|
25
|
+
table.add_column("ID", style="cyan")
|
|
26
|
+
table.add_column("Name")
|
|
27
|
+
table.add_column("Lat")
|
|
28
|
+
table.add_column("Lon")
|
|
29
|
+
for s in spots:
|
|
30
|
+
lat_s = f"{s.lat:.4f}" if s.lat is not None else ""
|
|
31
|
+
lon_s = f"{s.lon:.4f}" if s.lon is not None else ""
|
|
32
|
+
table.add_row(str(s.id), s.name, lat_s, lon_s)
|
|
33
|
+
console.print(table)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def print_models_table(rows: list[dict[str, object]]) -> None:
|
|
37
|
+
table = Table(title="Models")
|
|
38
|
+
table.add_column("ID")
|
|
39
|
+
table.add_column("Primary")
|
|
40
|
+
table.add_column("Aliases")
|
|
41
|
+
for row in rows:
|
|
42
|
+
aliases = row.get("aliases") or []
|
|
43
|
+
alias_s = ", ".join(str(a) for a in aliases) # type: ignore[arg-type]
|
|
44
|
+
table.add_row(str(row["id"]), str(row["primary"]), alias_s)
|
|
45
|
+
console.print(table)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_best(best: BestForecast) -> None:
|
|
49
|
+
console.print(
|
|
50
|
+
f"[bold]{best.spot.name}[/bold] · preset {best.preset} · top {len(best.models)}"
|
|
51
|
+
)
|
|
52
|
+
table = Table(title="WINDGURU DEFAULT weights")
|
|
53
|
+
table.add_column("#")
|
|
54
|
+
table.add_column("Model")
|
|
55
|
+
table.add_column("ID")
|
|
56
|
+
table.add_column("%", justify="right")
|
|
57
|
+
for m in best.models:
|
|
58
|
+
table.add_row(str(m.rank), m.name, str(m.id_model), f"{m.weight_pct:.1f}")
|
|
59
|
+
console.print(table)
|
|
60
|
+
for fc in best.forecasts:
|
|
61
|
+
print_forecast(fc)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def print_forecast(fc: Forecast) -> None:
|
|
65
|
+
title = f"{fc.spot.name} · {fc.model} ({fc.model_id})"
|
|
66
|
+
if fc.init:
|
|
67
|
+
title += f" · init {fc.init.strftime('%Y-%m-%d %HZ')}"
|
|
68
|
+
table = Table(title=title)
|
|
69
|
+
table.add_column("UTC", style="dim")
|
|
70
|
+
table.add_column("kt", justify="right")
|
|
71
|
+
table.add_column("Gust", justify="right")
|
|
72
|
+
table.add_column("Dir")
|
|
73
|
+
table.add_column("°C", justify="right")
|
|
74
|
+
table.add_column("mm", justify="right")
|
|
75
|
+
for row in fc.hours:
|
|
76
|
+
table.add_row(
|
|
77
|
+
row.time.strftime("%a %d %H:%M"),
|
|
78
|
+
_fmt(row.wind_kn, 1),
|
|
79
|
+
_fmt(row.gust_kn, 1),
|
|
80
|
+
f"{wind_dir_cardinal(row.wind_dir_deg)} {_fmt(row.wind_dir_deg, 0)}".strip(),
|
|
81
|
+
_fmt(row.temp_c, 1),
|
|
82
|
+
_fmt(row.precip_mm, 1),
|
|
83
|
+
)
|
|
84
|
+
console.print(table)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fmt(v: float | None, digits: int) -> str:
|
|
88
|
+
return "-" if v is None else f"{v:.{digits}f}"
|
guru/core/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared core utilities (CLI + MCP)."""
|
|
2
|
+
|
|
3
|
+
from guru.core.envelope import (
|
|
4
|
+
API_VERSION,
|
|
5
|
+
ERROR_SCHEMA,
|
|
6
|
+
dump_model,
|
|
7
|
+
error_payload,
|
|
8
|
+
success_payload,
|
|
9
|
+
)
|
|
10
|
+
from guru.core.errors import ErrorClassification, classify_error
|
|
11
|
+
from guru.core.instruct import instruct_payload
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"API_VERSION",
|
|
15
|
+
"ERROR_SCHEMA",
|
|
16
|
+
"ErrorClassification",
|
|
17
|
+
"classify_error",
|
|
18
|
+
"dump_model",
|
|
19
|
+
"error_payload",
|
|
20
|
+
"instruct_payload",
|
|
21
|
+
"success_payload",
|
|
22
|
+
]
|
guru/core/envelope.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared JSON envelope for CLI and MCP (fli-style agent contract)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from guru.core.errors import classify_error
|
|
8
|
+
from guru.search.exceptions import GuruAmbiguousError
|
|
9
|
+
|
|
10
|
+
API_VERSION = 1
|
|
11
|
+
|
|
12
|
+
ERROR_SCHEMA: dict[str, Any] = {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {
|
|
15
|
+
"ok": {"const": False},
|
|
16
|
+
"api_version": {"type": "integer"},
|
|
17
|
+
"error": {"type": "string"},
|
|
18
|
+
"error_type": {"type": "string"},
|
|
19
|
+
"retryable": {"type": "boolean"},
|
|
20
|
+
"http_status": {"type": "integer"},
|
|
21
|
+
"candidates": {"type": "array"},
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def success_payload(data: Any) -> dict[str, Any]:
|
|
27
|
+
return {"ok": True, "api_version": API_VERSION, "data": data}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def error_payload(exc: BaseException) -> dict[str, Any]:
|
|
31
|
+
classified = classify_error(exc)
|
|
32
|
+
payload: dict[str, Any] = {
|
|
33
|
+
"ok": False,
|
|
34
|
+
"api_version": API_VERSION,
|
|
35
|
+
"error": str(exc),
|
|
36
|
+
**classified.as_fields(),
|
|
37
|
+
}
|
|
38
|
+
if isinstance(exc, GuruAmbiguousError):
|
|
39
|
+
payload["candidates"] = [c.model_dump(mode="json") for c in exc.candidates]
|
|
40
|
+
return payload
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def dump_model(model: Any) -> Any:
|
|
44
|
+
"""Pydantic model → JSON-ready dict."""
|
|
45
|
+
return model.model_dump(mode="json")
|
guru/core/errors.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared error classification for CLI and MCP (fli-style)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from guru.search.exceptions import (
|
|
9
|
+
GuruAmbiguousError,
|
|
10
|
+
GuruError,
|
|
11
|
+
GuruHTTPError,
|
|
12
|
+
GuruNotFoundError,
|
|
13
|
+
GuruParseError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_RETRYABLE_HTTP = {429}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ErrorClassification:
|
|
21
|
+
error_type: str
|
|
22
|
+
retryable: bool
|
|
23
|
+
http_status: int | None = None
|
|
24
|
+
|
|
25
|
+
def as_fields(self) -> dict[str, Any]:
|
|
26
|
+
fields: dict[str, Any] = {
|
|
27
|
+
"error_type": self.error_type,
|
|
28
|
+
"retryable": self.retryable,
|
|
29
|
+
}
|
|
30
|
+
if self.http_status is not None:
|
|
31
|
+
fields["http_status"] = self.http_status
|
|
32
|
+
return fields
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def classify_error(exc: BaseException) -> ErrorClassification:
|
|
36
|
+
"""Map an exception to a stable ``error_type`` + ``retryable`` pair."""
|
|
37
|
+
if isinstance(exc, GuruAmbiguousError):
|
|
38
|
+
return ErrorClassification("ambiguous", retryable=False)
|
|
39
|
+
if isinstance(exc, GuruNotFoundError):
|
|
40
|
+
return ErrorClassification("not_found", retryable=False)
|
|
41
|
+
if isinstance(exc, GuruParseError):
|
|
42
|
+
return ErrorClassification("parse_error", retryable=False)
|
|
43
|
+
if isinstance(exc, GuruHTTPError):
|
|
44
|
+
status = getattr(exc, "status_code", None)
|
|
45
|
+
retryable = status is not None and (status in _RETRYABLE_HTTP or status >= 500)
|
|
46
|
+
return ErrorClassification("http_error", retryable=retryable, http_status=status)
|
|
47
|
+
if isinstance(exc, TimeoutError):
|
|
48
|
+
return ErrorClassification("timeout", retryable=True)
|
|
49
|
+
if isinstance(exc, ConnectionError | OSError):
|
|
50
|
+
return ErrorClassification("connection_error", retryable=True)
|
|
51
|
+
if isinstance(exc, ValueError):
|
|
52
|
+
return ErrorClassification("validation_error", retryable=False)
|
|
53
|
+
if isinstance(exc, GuruError):
|
|
54
|
+
return ErrorClassification("search_error", retryable=False)
|
|
55
|
+
msg = str(exc).lower()
|
|
56
|
+
if "timeout" in msg:
|
|
57
|
+
return ErrorClassification("timeout", retryable=True)
|
|
58
|
+
if "429" in msg or "rate" in msg:
|
|
59
|
+
return ErrorClassification("rate_limited", retryable=True, http_status=429)
|
|
60
|
+
return ErrorClassification("unexpected_error", retryable=False)
|
guru/core/instruct.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Agent recipe text (CLI ``instruct`` + MCP)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
INSTRUCT_STEPS: list[dict[str, Any]] = [
|
|
8
|
+
{
|
|
9
|
+
"step": 1,
|
|
10
|
+
"action": "search_spots",
|
|
11
|
+
"command": 'guru spots "<name>" --json',
|
|
12
|
+
"detail": (
|
|
13
|
+
"Search named Windguru spots. If multiple matches, pick an id "
|
|
14
|
+
"(do not guess)."
|
|
15
|
+
),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"step": 2,
|
|
19
|
+
"action": "near_spots",
|
|
20
|
+
"command": "guru near --lat <lat> --lon <lon> --json",
|
|
21
|
+
"detail": (
|
|
22
|
+
"Or find free named spots near coordinates (map markers). "
|
|
23
|
+
"Never use PRO lat/lon click-forecast."
|
|
24
|
+
),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"step": 3,
|
|
28
|
+
"action": "best_forecast",
|
|
29
|
+
"command": "guru best <id_spot> --json",
|
|
30
|
+
"detail": (
|
|
31
|
+
"Uses Tune preset WINDGURU_DEFAULT only: compute model weights "
|
|
32
|
+
"(resolution × freshness × preference), keep top 3, fetch those forecasts."
|
|
33
|
+
),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"step": 4,
|
|
37
|
+
"action": "ignore_rest",
|
|
38
|
+
"command": None,
|
|
39
|
+
"detail": (
|
|
40
|
+
"Ignore lower-weighted models unless the user asks for a specific "
|
|
41
|
+
"model via guru forecast -m."
|
|
42
|
+
),
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
INSTRUCT_SUMMARY = (
|
|
47
|
+
"For any place: resolve a named Windguru spot, then run guru best. "
|
|
48
|
+
"Always WINDGURU_DEFAULT Tune; trust only the top 3 models by weight. "
|
|
49
|
+
"Do not scrape windguru.cz HTML or the map canvas. Do not require PRO."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def instruct_payload() -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"summary": INSTRUCT_SUMMARY,
|
|
56
|
+
"preset": "WINDGURU_DEFAULT",
|
|
57
|
+
"top_models": 3,
|
|
58
|
+
"steps": INSTRUCT_STEPS,
|
|
59
|
+
"examples": [
|
|
60
|
+
'guru spots "castelldefels" --json',
|
|
61
|
+
"guru best 201 --json",
|
|
62
|
+
'guru spots "De Slufter" --json',
|
|
63
|
+
"guru best 48309 -H 24 --json",
|
|
64
|
+
"guru near --lat 51.9 --lon 4.1 --radius 40 --json",
|
|
65
|
+
],
|
|
66
|
+
}
|
guru/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP package."""
|
guru/mcp/_entry.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""MCP console-script entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run() -> None:
|
|
9
|
+
try:
|
|
10
|
+
from guru.mcp.server import mcp
|
|
11
|
+
except ModuleNotFoundError:
|
|
12
|
+
print(
|
|
13
|
+
"MCP dependencies are not installed.\n"
|
|
14
|
+
"Install them with: pip install 'windguru[mcp]'",
|
|
15
|
+
file=sys.stderr,
|
|
16
|
+
)
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
mcp.run()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_http() -> None:
|
|
22
|
+
try:
|
|
23
|
+
from guru.mcp.server import mcp
|
|
24
|
+
except ModuleNotFoundError:
|
|
25
|
+
print(
|
|
26
|
+
"MCP dependencies are not installed.\n"
|
|
27
|
+
"Install them with: pip install 'windguru[mcp]'",
|
|
28
|
+
file=sys.stderr,
|
|
29
|
+
)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
mcp.run(transport="http", host="127.0.0.1", port=8000)
|