agis-spatial 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.
- agis/__init__.py +3 -0
- agis/cli.py +349 -0
- agis/config.py +44 -0
- agis/context.py +307 -0
- agis/crs.py +92 -0
- agis/errors.py +26 -0
- agis/evals.py +224 -0
- agis/executor.py +236 -0
- agis/llm.py +58 -0
- agis/ops/__init__.py +6 -0
- agis/ops/attributes.py +175 -0
- agis/ops/base.py +75 -0
- agis/ops/geoprocessing.py +333 -0
- agis/ops/io.py +105 -0
- agis/ops/join.py +158 -0
- agis/ops/merge.py +83 -0
- agis/ops/tiling.py +184 -0
- agis/plan.py +69 -0
- agis/planner.py +210 -0
- agis/sample_data.py +85 -0
- agis/session.py +167 -0
- agis/setup.py +241 -0
- agis_spatial-0.1.0.dist-info/METADATA +145 -0
- agis_spatial-0.1.0.dist-info/RECORD +27 -0
- agis_spatial-0.1.0.dist-info/WHEEL +4 -0
- agis_spatial-0.1.0.dist-info/entry_points.txt +2 -0
- agis_spatial-0.1.0.dist-info/licenses/LICENSE +21 -0
agis/__init__.py
ADDED
agis/cli.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
|
|
16
|
+
from agis import __version__
|
|
17
|
+
from agis import config as config_mod
|
|
18
|
+
from agis.errors import AgisError, PlanError, StepError
|
|
19
|
+
|
|
20
|
+
# A file token, so "roads.gpkg" is one word and Tab can finish it.
|
|
21
|
+
_FILE_WORD = re.compile(r"[^\s\"']+")
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
err_console = Console(stderr=True)
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
add_completion=False,
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
rich_markup_mode="rich",
|
|
30
|
+
help="Natural-language GIS on your own machine.\n\n"
|
|
31
|
+
"Run [bold]agis[/bold] for an interactive prompt, or pass one request:\n"
|
|
32
|
+
'[bold]agis "clip roads.gpkg to county.shp and reproject to 3857"[/bold]',
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
ModelOpt = Annotated[str | None, typer.Option("--model", "-m", help="Ollama model to use.")]
|
|
36
|
+
HostOpt = Annotated[str | None, typer.Option("--host", help="Ollama URL.", show_default=False)]
|
|
37
|
+
DirOpt = Annotated[Path, typer.Option("--dir", "-C", help="Working directory.", file_okay=False,
|
|
38
|
+
exists=True)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _version(value: bool):
|
|
42
|
+
if value:
|
|
43
|
+
console.print(f"agis {__version__}")
|
|
44
|
+
raise typer.Exit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback()
|
|
48
|
+
def _root(
|
|
49
|
+
version: Annotated[bool, typer.Option("--version", callback=_version, is_eager=True,
|
|
50
|
+
help="Show the version and exit.")] = False,
|
|
51
|
+
):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _session(model: str | None, host: str | None, directory: Path):
|
|
56
|
+
from agis.llm import make_backend
|
|
57
|
+
from agis.session import Session
|
|
58
|
+
|
|
59
|
+
cfg = config_mod.load()
|
|
60
|
+
backend = make_backend(model or cfg.model, host or cfg.host)
|
|
61
|
+
return Session(backend=backend, cwd=directory.resolve(), console=console)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("run")
|
|
65
|
+
def run_cmd(
|
|
66
|
+
request: Annotated[list[str], typer.Argument(help="What you want done, in plain English.")],
|
|
67
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="Show the plan only.")] = False,
|
|
68
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't ask before running.")] = False,
|
|
69
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing outputs.")] = False,
|
|
70
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show context and raw plans.")] = False,
|
|
71
|
+
model: ModelOpt = None,
|
|
72
|
+
host: HostOpt = None,
|
|
73
|
+
directory: DirOpt = Path("."),
|
|
74
|
+
):
|
|
75
|
+
"""Plan and run a request. This is the default command, so `agis run` can be omitted."""
|
|
76
|
+
from agis.session import Options
|
|
77
|
+
|
|
78
|
+
session = _session(model, host, directory)
|
|
79
|
+
with _errors():
|
|
80
|
+
session.handle(" ".join(request), Options(dry_run=dry_run, yes=yes, force=force,
|
|
81
|
+
verbose=verbose))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command()
|
|
85
|
+
def repl(
|
|
86
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't ask before running.")] = False,
|
|
87
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing outputs.")] = False,
|
|
88
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
89
|
+
model: ModelOpt = None,
|
|
90
|
+
host: HostOpt = None,
|
|
91
|
+
directory: DirOpt = Path("."),
|
|
92
|
+
):
|
|
93
|
+
"""Interactive mode. Later requests can refer to files made earlier ("now buffer that")."""
|
|
94
|
+
from agis.session import Options
|
|
95
|
+
|
|
96
|
+
session = _session(model, host, directory)
|
|
97
|
+
options = Options(yes=yes, force=force, verbose=verbose)
|
|
98
|
+
completer = DatasetCompleter(session.cwd)
|
|
99
|
+
prompt = _prompt_session(completer)
|
|
100
|
+
console.print(f"agis {__version__} using [bold]{session.backend.model}[/bold] in "
|
|
101
|
+
f"{session.cwd}. Type a request, or 'exit' to quit. "
|
|
102
|
+
"ls, cd, and pwd run locally.")
|
|
103
|
+
while True:
|
|
104
|
+
try:
|
|
105
|
+
request = _read_request(prompt)
|
|
106
|
+
except EOFError:
|
|
107
|
+
console.print()
|
|
108
|
+
return
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
# At the prompt, cancel this line and keep the session. A Ctrl-C during a
|
|
111
|
+
# run is handled inside the loop below and also returns here.
|
|
112
|
+
if prompt is None:
|
|
113
|
+
console.print()
|
|
114
|
+
return
|
|
115
|
+
console.print()
|
|
116
|
+
continue
|
|
117
|
+
if request.lower() in ("exit", "quit", ":q"):
|
|
118
|
+
return
|
|
119
|
+
if not request:
|
|
120
|
+
continue
|
|
121
|
+
if try_builtin(request, session, completer, console):
|
|
122
|
+
continue
|
|
123
|
+
with _errors(exit_on_error=False):
|
|
124
|
+
session.handle(request, options)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command()
|
|
128
|
+
def setup(
|
|
129
|
+
model: ModelOpt = None,
|
|
130
|
+
host: HostOpt = None,
|
|
131
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Accept the recommended model.")] = False,
|
|
132
|
+
):
|
|
133
|
+
"""Check Ollama, download the recommended model, and save the choice."""
|
|
134
|
+
from agis.setup import run_setup
|
|
135
|
+
|
|
136
|
+
with _errors():
|
|
137
|
+
run_setup(console, model=model, host=host, assume_yes=yes)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def doctor(host: HostOpt = None):
|
|
142
|
+
"""Print environment details that are useful for troubleshooting and bug reports."""
|
|
143
|
+
from agis.setup import run_doctor
|
|
144
|
+
|
|
145
|
+
with _errors():
|
|
146
|
+
if not run_doctor(console, host=host):
|
|
147
|
+
raise typer.Exit(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command("eval")
|
|
151
|
+
def eval_cmd(
|
|
152
|
+
model: ModelOpt = None,
|
|
153
|
+
host: HostOpt = None,
|
|
154
|
+
cases: Annotated[Path, typer.Option("--cases", help="YAML file of eval cases.")] = Path(
|
|
155
|
+
"tests/evals/cases.yaml"),
|
|
156
|
+
only: Annotated[str | None, typer.Option("--only", help="Run cases whose id contains this.")] = None,
|
|
157
|
+
show: Annotated[bool, typer.Option("--show", help="Print every plan, not only failures.")] = False,
|
|
158
|
+
):
|
|
159
|
+
"""Score how well a model turns the eval prompts into the expected plans."""
|
|
160
|
+
from agis.evals import run_evals
|
|
161
|
+
|
|
162
|
+
cfg = config_mod.load()
|
|
163
|
+
with _errors():
|
|
164
|
+
score = run_evals(console, cases, model or cfg.model, host or cfg.host, only=only,
|
|
165
|
+
show=show)
|
|
166
|
+
raise typer.Exit(0 if score == 1.0 else 1)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _errors:
|
|
170
|
+
"""Print AgisErrors nicely instead of a traceback."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, exit_on_error: bool = True):
|
|
173
|
+
self.exit_on_error = exit_on_error
|
|
174
|
+
|
|
175
|
+
def __enter__(self):
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
def __exit__(self, exc_type, exc, tb):
|
|
179
|
+
if exc_type is None or not issubclass(exc_type, (AgisError, KeyboardInterrupt)):
|
|
180
|
+
return False
|
|
181
|
+
if exc_type is KeyboardInterrupt:
|
|
182
|
+
err_console.print("\nInterrupted.")
|
|
183
|
+
elif isinstance(exc, PlanError) and len(exc.problems) > 1:
|
|
184
|
+
err_console.print("[red]The plan can't run:[/red]")
|
|
185
|
+
for problem in exc.problems:
|
|
186
|
+
err_console.print(f" - {escape(problem)}")
|
|
187
|
+
elif isinstance(exc, PlanError):
|
|
188
|
+
err_console.print(f"[red]The plan can't run:[/red] {escape(exc.problems[0])}")
|
|
189
|
+
elif isinstance(exc, StepError):
|
|
190
|
+
err_console.print(f"[red]Step {exc.index} ({exc.op}) failed:[/red] "
|
|
191
|
+
f"{escape(str(exc.cause))}")
|
|
192
|
+
else:
|
|
193
|
+
err_console.print(f"[red]Error:[/red] {escape(str(exc))}")
|
|
194
|
+
if self.exit_on_error:
|
|
195
|
+
raise typer.Exit(130 if exc_type is KeyboardInterrupt else 1)
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
SUBCOMMANDS = {"run", "repl", "setup", "doctor", "eval"}
|
|
200
|
+
ROOT_FLAGS = {"--help", "-h", "--version"}
|
|
201
|
+
# Flags accepted by both `run` and `repl`. True when the next argv token is the value,
|
|
202
|
+
# unless the flag is written as --name=value.
|
|
203
|
+
_SHARED_FLAGS = {
|
|
204
|
+
"-y": False, "--yes": False,
|
|
205
|
+
"-f": False, "--force": False,
|
|
206
|
+
"-v": False, "--verbose": False,
|
|
207
|
+
"-m": True, "--model": True,
|
|
208
|
+
"--host": True,
|
|
209
|
+
"-C": True, "--dir": True,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def argv_for(args: list[str]) -> list[str]:
|
|
214
|
+
"""Turn a bare invocation into the repl, and a request into `run`.
|
|
215
|
+
|
|
216
|
+
Flags with no request (`agis -C ~/data`) also open the repl. A run-only flag such as
|
|
217
|
+
`--dry-run` still goes to `run`, so a missing request is reported there.
|
|
218
|
+
"""
|
|
219
|
+
if not args:
|
|
220
|
+
return ["repl"]
|
|
221
|
+
if args[0] in SUBCOMMANDS or args[0] in ROOT_FLAGS:
|
|
222
|
+
return list(args)
|
|
223
|
+
if _only_shared_flags(args):
|
|
224
|
+
return ["repl", *args]
|
|
225
|
+
return ["run", *args]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _only_shared_flags(args: list[str]) -> bool:
|
|
229
|
+
index = 0
|
|
230
|
+
while index < len(args):
|
|
231
|
+
name, separator, _value = args[index].partition("=")
|
|
232
|
+
if name not in _SHARED_FLAGS:
|
|
233
|
+
return False
|
|
234
|
+
takes_value = _SHARED_FLAGS[name] and not separator
|
|
235
|
+
index += 2 if takes_value else 1
|
|
236
|
+
if takes_value and index > len(args):
|
|
237
|
+
return False
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _prompt_session(completer: DatasetCompleter):
|
|
242
|
+
"""Line editor with history and file completion, or None when stdin is piped."""
|
|
243
|
+
if not sys.stdin.isatty():
|
|
244
|
+
return None
|
|
245
|
+
from prompt_toolkit import PromptSession
|
|
246
|
+
from prompt_toolkit.history import FileHistory
|
|
247
|
+
|
|
248
|
+
path = config_mod.config_path().parent / "history"
|
|
249
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
return PromptSession(history=FileHistory(str(path)), completer=completer,
|
|
251
|
+
complete_while_typing=False)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_BUILTINS = {"ls", "cd", "pwd"}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def try_builtin(line: str, session, completer: DatasetCompleter | None, console: Console) -> bool:
|
|
258
|
+
"""Run ls, cd, or pwd locally. Anything else, including a pipe, returns False."""
|
|
259
|
+
if any(character in line for character in "|;&><"):
|
|
260
|
+
return False
|
|
261
|
+
try:
|
|
262
|
+
argv = shlex.split(line)
|
|
263
|
+
except ValueError:
|
|
264
|
+
return False
|
|
265
|
+
if not argv or argv[0] not in _BUILTINS:
|
|
266
|
+
return False
|
|
267
|
+
command, args = argv[0], argv[1:]
|
|
268
|
+
if command == "ls":
|
|
269
|
+
_ls(args, session, console)
|
|
270
|
+
elif command == "cd":
|
|
271
|
+
_cd(args, session, completer, console)
|
|
272
|
+
else:
|
|
273
|
+
_pwd(args, session, console)
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _ls(args: list[str], session, console: Console) -> None:
|
|
278
|
+
binary = shutil.which("ls")
|
|
279
|
+
if binary is None:
|
|
280
|
+
console.print("ls is not available on this system.", style="red")
|
|
281
|
+
return
|
|
282
|
+
# ls writes the listing itself. Its own message covers a bad path.
|
|
283
|
+
subprocess.run([binary, *args], cwd=session.cwd, check=False)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _cd(args: list[str], session, completer: DatasetCompleter | None, console: Console) -> None:
|
|
287
|
+
if len(args) > 1:
|
|
288
|
+
console.print("cd takes one directory.", style="red")
|
|
289
|
+
return
|
|
290
|
+
raw = args[0] if args else "~"
|
|
291
|
+
target = Path(raw).expanduser()
|
|
292
|
+
if not target.is_absolute():
|
|
293
|
+
target = session.cwd / target
|
|
294
|
+
try:
|
|
295
|
+
target = target.resolve()
|
|
296
|
+
except OSError as exc:
|
|
297
|
+
console.print(str(exc), style="red", markup=False)
|
|
298
|
+
return
|
|
299
|
+
if not target.is_dir():
|
|
300
|
+
console.print(f"not a directory: {raw}", style="red", markup=False)
|
|
301
|
+
return
|
|
302
|
+
session.cwd = target
|
|
303
|
+
if completer is not None:
|
|
304
|
+
completer.cwd = target
|
|
305
|
+
console.print(str(target), markup=False)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _pwd(args: list[str], session, console: Console) -> None:
|
|
309
|
+
if args:
|
|
310
|
+
console.print("pwd takes no arguments.", style="red")
|
|
311
|
+
return
|
|
312
|
+
console.print(str(session.cwd), markup=False)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class DatasetCompleter(Completer):
|
|
316
|
+
"""Tab-completes dataset names in one directory. Listing them does not open the files."""
|
|
317
|
+
|
|
318
|
+
def __init__(self, cwd: Path):
|
|
319
|
+
self.cwd = cwd
|
|
320
|
+
|
|
321
|
+
def get_completions(self, document, complete_event):
|
|
322
|
+
from agis.context import find_datasets
|
|
323
|
+
|
|
324
|
+
word = document.get_word_before_cursor(pattern=_FILE_WORD)
|
|
325
|
+
if not word:
|
|
326
|
+
return
|
|
327
|
+
prefix = word.casefold()
|
|
328
|
+
for path in find_datasets(self.cwd):
|
|
329
|
+
name = path.name
|
|
330
|
+
if name.casefold().startswith(prefix):
|
|
331
|
+
insert = f'"{name}"' if " " in name else name
|
|
332
|
+
yield Completion(insert, start_position=-len(word), display=name)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _read_request(prompt) -> str:
|
|
336
|
+
if prompt is None:
|
|
337
|
+
return input("agis> ").strip()
|
|
338
|
+
from prompt_toolkit.formatted_text import HTML
|
|
339
|
+
|
|
340
|
+
return prompt.prompt(HTML("<b><ansicyan>agis></ansicyan></b> ")).strip()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def main() -> None:
|
|
344
|
+
"""Entry point. A bare `agis` opens the repl; other text is a one-shot request."""
|
|
345
|
+
app(args=argv_for(sys.argv[1:]), prog_name="agis")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
if __name__ == "__main__":
|
|
349
|
+
main()
|
agis/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DEFAULT_MODEL = "qwen2.5:3b"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def config_path() -> Path:
|
|
12
|
+
base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
|
|
13
|
+
return Path(base) / "agis" / "config.toml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Config:
|
|
18
|
+
model: str = DEFAULT_MODEL
|
|
19
|
+
host: str | None = None
|
|
20
|
+
from_file: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load() -> Config:
|
|
24
|
+
"""Settings from the config file, overridden by AGIS_MODEL and OLLAMA_HOST."""
|
|
25
|
+
config = Config()
|
|
26
|
+
path = config_path()
|
|
27
|
+
if path.exists():
|
|
28
|
+
data = tomllib.loads(path.read_text())
|
|
29
|
+
config.model = data.get("model", config.model)
|
|
30
|
+
config.host = data.get("host") or None
|
|
31
|
+
config.from_file = True
|
|
32
|
+
config.model = os.environ.get("AGIS_MODEL", config.model)
|
|
33
|
+
config.host = os.environ.get("OLLAMA_HOST", config.host)
|
|
34
|
+
return config
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save(model: str, host: str | None = None) -> Path:
|
|
38
|
+
path = config_path()
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
lines = [f'model = "{model}"']
|
|
41
|
+
if host:
|
|
42
|
+
lines.append(f'host = "{host}"')
|
|
43
|
+
path.write_text("\n".join(lines) + "\n")
|
|
44
|
+
return path
|