artificial-intelligence-local 0.0.1b3046__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.
File without changes
File without changes
@@ -0,0 +1,6 @@
1
+ """Contacts agent (stub).
2
+
3
+ Filled in PR4: wraps contact_local's get_phone_number_by_name as a Strands
4
+ @tool (same pattern as the weather agent), routed through local Ollama.
5
+ Answers "What's Tal's phone number?".
6
+ """
@@ -0,0 +1,239 @@
1
+ """Auto-generate read-only Strands agent wrappers for our *_local packages.
2
+
3
+ Reads a ``*_local.py`` source file WITHOUT importing it -- so it works even
4
+ when the target package cannot import without the :3000 auth server and a live
5
+ DB -- finds the main class, and emits a ``<module>_strands.py`` file containing:
6
+
7
+ * a module docstring
8
+ * a ``_<factory>()`` helper returning a fresh instance of the class
9
+ * one ``<method>_strand`` twin per read-only method that:
10
+ - drops ``self``, keeps the exact param/return annotations,
11
+ - calls the same-named method on a fresh instance,
12
+ - stores the result in ``<method>_strand_result`` and returns it,
13
+ - carries a ``"Strand twin of <Class>.<method>."`` docstring
14
+
15
+ Read-only selection rule: the method name starts with ``get_``, its first
16
+ parameter is ``self``, it is not a ``get_test_*`` helper, not private, and not
17
+ a @staticmethod/@classmethod. Only methods defined on the class itself are
18
+ wrapped -- inherited getters from a base class are not.
19
+
20
+ The emitted file is a draft: run it through the repo formatter (black/isort)
21
+ and review before committing.
22
+
23
+ Usage:
24
+ python -m strands_wrapper_generator <path/to/foo_local.py> [--out DIR]
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import ast
31
+ import os
32
+
33
+ READ_PREFIXES = ("get_",) # wrap read-only getters only
34
+
35
+ _BUILTIN_NAMES = {
36
+ "int", "str", "float", "bool", "bytes", "dict", "list", "tuple", "set",
37
+ "frozenset", "object", "None", "any", "type", "complex", "bytearray",
38
+ }
39
+
40
+
41
+ def _pick_main_class(tree: ast.Module) -> ast.ClassDef:
42
+ """First top-level class in the file (the *_local classes are singletons)."""
43
+ classes = [n for n in tree.body if isinstance(n, ast.ClassDef)]
44
+ if not classes:
45
+ raise SystemExit("no top-level class found in source")
46
+ return classes[0]
47
+
48
+
49
+ def _decorator_names(fn: ast.FunctionDef) -> set[str]:
50
+ names = set()
51
+ for d in fn.decorator_list:
52
+ if isinstance(d, ast.Name):
53
+ names.add(d.id)
54
+ elif isinstance(d, ast.Attribute):
55
+ names.add(d.attr)
56
+ return names
57
+
58
+
59
+ def _first_param_is_self(fn: ast.FunctionDef) -> bool:
60
+ args = fn.args.posonlyargs + fn.args.args
61
+ return bool(args) and args[0].arg == "self"
62
+
63
+
64
+ def _is_read_only_instance_method(fn: ast.stmt) -> bool:
65
+ if not isinstance(fn, ast.FunctionDef):
66
+ return False
67
+ name = fn.name
68
+ if name.startswith("_"):
69
+ return False
70
+ if name.startswith("get_test"):
71
+ return False
72
+ if not name.startswith(READ_PREFIXES):
73
+ return False
74
+ if _decorator_names(fn) & {"staticmethod", "classmethod"}:
75
+ return False
76
+ return _first_param_is_self(fn)
77
+
78
+
79
+ def _factory_name(class_name: str) -> str:
80
+ """ContactsLocal -> _contacts (strip 'Local', lowercase)."""
81
+ base = class_name[:-5] if class_name.endswith("Local") else class_name
82
+ return "_" + base.lower()
83
+
84
+
85
+ def _params_without_self(fn: ast.FunctionDef) -> tuple[str, str]:
86
+ """Return (signature_params, call_args) reproduced from the AST.
87
+
88
+ signature_params: 'contact_id: int' (self dropped, annotations kept)
89
+ call_args: 'contact_id'
90
+ """
91
+ a = fn.args
92
+ pos = a.posonlyargs + a.args
93
+ # drop leading self
94
+ pos = pos[1:] if pos and pos[0].arg == "self" else pos
95
+
96
+ defaults = a.defaults
97
+ n_defaults = len(defaults)
98
+ first_default = len(pos) - n_defaults
99
+
100
+ sig_parts: list[str] = []
101
+ call_parts: list[str] = []
102
+ for i, arg in enumerate(pos):
103
+ piece = arg.arg
104
+ if arg.annotation is not None:
105
+ piece += f": {ast.unparse(arg.annotation)}"
106
+ if i >= first_default:
107
+ piece += f" = {ast.unparse(defaults[i - first_default])}"
108
+ sig_parts.append(piece)
109
+ call_parts.append(arg.arg)
110
+
111
+ # keyword-only args (rare here, handled for robustness)
112
+ for arg, default in zip(a.kwonlyargs, a.kw_defaults):
113
+ piece = arg.arg
114
+ if arg.annotation is not None:
115
+ piece += f": {ast.unparse(arg.annotation)}"
116
+ if default is not None:
117
+ piece += f" = {ast.unparse(default)}"
118
+ sig_parts.append(piece)
119
+ call_parts.append(f"{arg.arg}={arg.arg}")
120
+
121
+ return ", ".join(sig_parts), ", ".join(call_parts)
122
+
123
+
124
+ def _render_twin(fn: ast.FunctionDef, class_name: str, factory: str) -> str:
125
+ name = fn.name
126
+ sig, call = _params_without_self(fn)
127
+ ret = f" -> {ast.unparse(fn.returns)}" if fn.returns is not None else ""
128
+ result_var = f"{name}_strand_result"
129
+ return (
130
+ f"def {name}_strand({sig}){ret}:\n"
131
+ f' """Strand twin of {class_name}.{name}."""\n'
132
+ f" {result_var} = {factory}().{name}({call})\n"
133
+ f" return {result_var}\n"
134
+ )
135
+
136
+
137
+ def _import_map(tree: ast.Module) -> dict[str, str]:
138
+ """Map each top-level imported name -> the import statement that binds it."""
139
+ m: dict[str, str] = {}
140
+ for node in tree.body:
141
+ if isinstance(node, ast.ImportFrom):
142
+ mod = "." * (node.level or 0) + (node.module or "")
143
+ for a in node.names:
144
+ bound = a.asname or a.name
145
+ m[bound] = f"from {mod} import {a.name}" + (f" as {a.asname}" if a.asname else "")
146
+ elif isinstance(node, ast.Import):
147
+ for a in node.names:
148
+ bound = a.asname or a.name.split(".")[0]
149
+ m[bound] = f"import {a.name}" + (f" as {a.asname}" if a.asname else "")
150
+ return m
151
+
152
+
153
+ def _names_in_annotations(methods: list[ast.FunctionDef]) -> set[str]:
154
+ """Every top-level name referenced by the wrapped methods' annotations."""
155
+ names: set[str] = set()
156
+ for fn in methods:
157
+ anns = [a.annotation for a in (fn.args.posonlyargs + fn.args.args + fn.args.kwonlyargs) if a.annotation]
158
+ if fn.returns is not None:
159
+ anns.append(fn.returns)
160
+ for ann in anns:
161
+ for n in ast.walk(ann):
162
+ if isinstance(n, ast.Name):
163
+ names.add(n.id)
164
+ elif isinstance(n, ast.Attribute):
165
+ root = n
166
+ while isinstance(root, ast.Attribute):
167
+ root = root.value
168
+ if isinstance(root, ast.Name):
169
+ names.add(root.id)
170
+ return names
171
+
172
+
173
+ def generate(source_path: str) -> str:
174
+ """Return the source text of the ``*_strands.py`` wrapper for ``source_path``."""
175
+ with open(source_path, "r", encoding="utf-8") as f:
176
+ src = f.read()
177
+ tree = ast.parse(src)
178
+ cls = _pick_main_class(tree)
179
+ class_name = cls.name
180
+ module_name = os.path.splitext(os.path.basename(source_path))[0] # e.g. contact_local
181
+ factory = _factory_name(class_name)
182
+
183
+ # dedupe by name, keeping the last definition (mirrors Python's own
184
+ # redefinition semantics; some sources define the same method twice).
185
+ by_name: dict[str, ast.FunctionDef] = {}
186
+ for n in cls.body:
187
+ if _is_read_only_instance_method(n):
188
+ by_name[n.name] = n
189
+ methods = list(by_name.values())
190
+
191
+ # carry over imports the wrapped annotations reference (e.g. typing.Dict)
192
+ imap = _import_map(tree)
193
+ extra_imports = sorted(
194
+ {imap[nm] for nm in _names_in_annotations(methods) if nm not in _BUILTIN_NAMES and nm in imap}
195
+ )
196
+
197
+ out: list[str] = []
198
+ out.append(
199
+ f'"""Read-only Strands agent wrapper over {class_name}.\n\n'
200
+ f'Each function here is a thin "_strand" twin of an existing {class_name}\n'
201
+ f"method: ``<method>_strand`` simply calls ``<method>`` and returns its\n"
202
+ f"result. No new business logic lives here -- all logic stays in\n"
203
+ f"{class_name}. These functions are what the local AI agent wires in as\n"
204
+ f"Strands tools.\n\n"
205
+ f"AUTO-GENERATED by strands_wrapper_generator.py -- do not edit by hand.\n"
206
+ f'"""\n'
207
+ )
208
+ import_lines = [f"from .{module_name} import {class_name}"] + extra_imports
209
+ out.append("\n".join(import_lines) + "\n")
210
+ out.append(
211
+ f"def {factory}() -> {class_name}:\n"
212
+ f' """Return a fresh {class_name} instance to call existing methods on."""\n'
213
+ f" return {class_name}()\n"
214
+ )
215
+ for fn in methods:
216
+ out.append(_render_twin(fn, class_name, factory))
217
+
218
+ return "\n\n".join(out) + "\n"
219
+
220
+
221
+ def main() -> None:
222
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
223
+ ap.add_argument("source", help="path to a *_local.py source file")
224
+ ap.add_argument("--out", default=None, help="dir to write <module>_strands.py; default = stdout")
225
+ args = ap.parse_args()
226
+
227
+ code = generate(args.source)
228
+ if args.out:
229
+ module_name = os.path.splitext(os.path.basename(args.source))[0]
230
+ dest = os.path.join(args.out, f"{module_name}_strands.py")
231
+ with open(dest, "w", encoding="utf-8") as f:
232
+ f.write(code)
233
+ print(f"wrote {dest}")
234
+ else:
235
+ print(code, end="")
236
+
237
+
238
+ if __name__ == "__main__":
239
+ main()
@@ -0,0 +1,212 @@
1
+ """Ollama weather agent.
2
+
3
+ A local-first Strands weather agent that follows the tool-based pattern from the
4
+ Strands samples repo (python/07-ux-demos/triage-agent weather server): the
5
+ National Weather Service two-step (points -> forecast) lives inside a Python
6
+ `get_forecast` tool, and a local Ollama model just decides the coordinates and
7
+ phrases the result.
8
+
9
+ Running the NWS calls in code -- instead of handing the model the generic
10
+ `http_request` tool and asking it to build URLs and parse JSON itself -- makes
11
+ small local models far more reliable: they only pick coordinates, call one tool,
12
+ and summarize the answer.
13
+ """
14
+
15
+ import os
16
+ import sys
17
+
18
+ import ollama
19
+ import requests
20
+ from dotenv import find_dotenv, load_dotenv
21
+ from strands import Agent, tool
22
+ from strands.models.ollama import OllamaModel
23
+
24
+ # Load OLLAMA_* settings from a local .env.local1 file if one exists
25
+ # (see .env.local1.example). find_dotenv searches up from the current working
26
+ # directory, so this works whichever sub-directory you launch the agent from.
27
+ load_dotenv(find_dotenv(".env.local1", usecwd=True))
28
+
29
+ # Free US National Weather Service API. It requires a descriptive User-Agent;
30
+ # requests without one can be rejected.
31
+ NWS_API_BASE = "https://api.weather.gov"
32
+ NWS_HEADERS = {
33
+ "User-Agent": "circlez-ai-local-weather-agent (contact: dev@circ.zone)",
34
+ "Accept": "application/geo+json",
35
+ }
36
+
37
+
38
+ @tool
39
+ def get_forecast(latitude: float, longitude: float) -> str:
40
+ """Get the current weather forecast for a US location.
41
+
42
+ Args:
43
+ latitude: Latitude in decimal degrees (e.g. 47.6062 for Seattle).
44
+ longitude: Longitude in decimal degrees (e.g. -122.3321 for Seattle).
45
+
46
+ Returns:
47
+ A short human-readable forecast for the upcoming period, or a message
48
+ explaining that no forecast is available (e.g. the coordinates are
49
+ outside the US, which the NWS does not cover).
50
+ """
51
+ try:
52
+ points = requests.get(
53
+ f"{NWS_API_BASE}/points/{latitude},{longitude}",
54
+ headers=NWS_HEADERS,
55
+ timeout=10,
56
+ )
57
+ if points.status_code != 200:
58
+ return (
59
+ f"No weather data for {latitude},{longitude} -- the National "
60
+ "Weather Service only covers locations in the United States."
61
+ )
62
+ forecast_url = points.json()["properties"]["forecast"]
63
+
64
+ forecast = requests.get(forecast_url, headers=NWS_HEADERS, timeout=10)
65
+ forecast.raise_for_status()
66
+ period = forecast.json()["properties"]["periods"][0]
67
+ except (requests.RequestException, KeyError, IndexError) as error:
68
+ return f"Could not retrieve the forecast: {error}"
69
+
70
+ return (
71
+ f"{period['name']}: {period['temperature']}°{period['temperatureUnit']}, "
72
+ f"{period['shortForecast']} "
73
+ f"(wind {period['windSpeed']} {period['windDirection']})."
74
+ )
75
+
76
+
77
+ WEATHER_SYSTEM_PROMPT = """You are a weather assistant for locations in the United States.
78
+
79
+ You have one tool: get_forecast(latitude, longitude). To answer a weather question:
80
+ 1. Work out the latitude and longitude of the location from your own knowledge.
81
+ 2. Call get_forecast with those coordinates.
82
+ 3. Reply in ONE or TWO short sentences that state the temperature and the conditions.
83
+
84
+ Rules -- follow them strictly:
85
+ - Do NOT narrate your steps or say what you are "about to" do. Call the tool, then
86
+ give only the final answer.
87
+ - If get_forecast says there is no data or that it could not retrieve the forecast,
88
+ tell the user you could not get weather for that location. Never invent a forecast.
89
+ - If the location is not a real US place, say you cannot find it instead of guessing.
90
+ """
91
+
92
+ def build_agent(model_id):
93
+ """Build the weather Agent backed by a local Ollama model.
94
+
95
+ Kept as a factory so the interactive picker can build the agent with the
96
+ model the user chose at startup, while imports/tests still get a ready-made
97
+ agent from the configured default (see ``weather_agent`` below).
98
+ """
99
+ model = OllamaModel(
100
+ host=os.getenv("OLLAMA_HOST", "http://localhost:11434"),
101
+ model_id=model_id,
102
+ )
103
+ return Agent(
104
+ model=model,
105
+ system_prompt=WEATHER_SYSTEM_PROMPT,
106
+ tools=[get_forecast],
107
+ )
108
+
109
+
110
+ def available_ollama_models():
111
+ """Return the locally-pulled Ollama model ids, e.g. ``["qwen2.5:7b", ...]``.
112
+
113
+ Uses the Ollama Python client. Returns an empty list if Ollama is not
114
+ running or has no models pulled, so callers can fall back to the default.
115
+ """
116
+ try:
117
+ response = ollama.list()
118
+ except Exception: # noqa: BLE001 - Ollama may be down; caller falls back to the default
119
+ return []
120
+ # ollama-python >= 0.4 returns an object with a ``.models`` list of items
121
+ # exposing ``.model``; older versions return a dict of {"name"/"model": ...}.
122
+ models = getattr(response, "models", None)
123
+ if models is None and isinstance(response, dict):
124
+ models = response.get("models", [])
125
+ names = []
126
+ for model in models or []:
127
+ name = getattr(model, "model", None)
128
+ if name is None and isinstance(model, dict):
129
+ name = model.get("model") or model.get("name")
130
+ if name:
131
+ names.append(name)
132
+ return names
133
+
134
+
135
+ def choose_model(default_model_id):
136
+ """Let the user pick from the locally-installed Ollama models at startup.
137
+
138
+ Falls back to ``default_model_id`` when input is non-interactive (tests /
139
+ CI / piped stdin), when Ollama lists no models, or when the user just
140
+ presses Enter.
141
+ """
142
+ models = available_ollama_models()
143
+ if not models or not sys.stdin.isatty():
144
+ return default_model_id
145
+
146
+ default_installed = default_model_id in models
147
+ fallback = default_model_id if default_installed else models[0]
148
+ if not default_installed:
149
+ # Don't silently redirect: tell the user their configured default
150
+ # isn't pulled, so the "(default)" marker below (which only marks the
151
+ # real configured default) isn't misleading.
152
+ print(
153
+ f"Configured default {default_model_id!r} is not installed; "
154
+ f"falling back to {fallback!r}."
155
+ )
156
+ print("Available Ollama models:")
157
+ for index, name in enumerate(models, start=1):
158
+ marker = " (default)" if name == default_model_id else ""
159
+ print(f" {index}. {name}{marker}")
160
+
161
+ try:
162
+ choice = input(f"Pick a model by number [default: {fallback}]: ").strip()
163
+ except (KeyboardInterrupt, EOFError):
164
+ return fallback
165
+ if not choice:
166
+ return fallback
167
+ if choice.isdigit() and 1 <= int(choice) <= len(models):
168
+ return models[int(choice) - 1]
169
+ print(f"'{choice}' is not a valid choice; using {fallback}.")
170
+ return fallback
171
+
172
+
173
+ # The model (the "brain"): a local Ollama model instead of a cloud provider.
174
+ # Configure the default in your .env.local1 (copy .env.local1.example ->
175
+ # .env.local1). You must have Ollama installed and the model pulled first:
176
+ # `ollama pull <model_id>`. Running the module interactively lets you pick a
177
+ # different installed model at startup (see choose_model / __main__).
178
+ OLLAMA_MODEL_ID = os.getenv("OLLAMA_MODEL_ID", "qwen2.5:7b")
179
+
180
+ # A ready-made agent on the configured default, so importing the module (and the
181
+ # tests) get a working agent without any interactive prompt.
182
+ weather_agent = build_agent(OLLAMA_MODEL_ID)
183
+
184
+
185
+ # Interactive loop so we can test it from the terminal.
186
+ if __name__ == "__main__":
187
+ selected_model_id = choose_model(OLLAMA_MODEL_ID)
188
+ # Rebuild only if the user picked something other than the default the
189
+ # module-level agent was already built with.
190
+ agent = weather_agent if selected_model_id == OLLAMA_MODEL_ID else build_agent(selected_model_id)
191
+
192
+ print(f"\nOllama Weather Agent (local {selected_model_id})\n")
193
+ print("Ask about the weather in a US location, e.g.:")
194
+ print(" What's the weather like in Seattle?")
195
+ print("Type 'exit' to quit.\n")
196
+
197
+ while True:
198
+ try:
199
+ user_input = input("> ").strip()
200
+ if not user_input:
201
+ continue
202
+ if user_input.lower() == "exit":
203
+ print("\nGoodbye!")
204
+ break
205
+ # The agent streams its answer to stdout itself, so we don't reprint it.
206
+ agent(user_input)
207
+ print()
208
+ except (KeyboardInterrupt, EOFError):
209
+ print("\nExiting...")
210
+ break
211
+ except Exception as error: # noqa: BLE001 - surface any error to the user
212
+ print(f"\nError: {error}\nPlease try a different request.")
File without changes
@@ -0,0 +1,10 @@
1
+ """Placeholder test for the artificial-intelligence-local package.
2
+
3
+ The template example tests were removed in the BU-3046 cleanup. Real tests
4
+ (e.g. the weather agent and the contacts agent) are added in later PRs.
5
+ This keeps the test suite green so the build/GHA has at least one test to collect.
6
+ """
7
+
8
+
9
+ def test_placeholder():
10
+ assert True
@@ -0,0 +1 @@
1
+ """Tests for the contacts agent (stub). Filled in PR4."""
@@ -0,0 +1,107 @@
1
+ """Tests for strands_wrapper_generator.
2
+
3
+ These are pure AST/codegen tests -- no DB, no :3000 auth server -- driven by a
4
+ small in-memory fixture class that exercises every selection rule.
5
+ """
6
+
7
+ import ast
8
+ import os
9
+ import tempfile
10
+
11
+ import strands_wrapper_generator as gen
12
+
13
+ # A fixture *_local.py source covering every case the selection rule must handle.
14
+ FIXTURE_SOURCE = '''
15
+ from typing import Dict
16
+
17
+
18
+ class WidgetsLocal(SomeBase):
19
+ def __init__(self, x=None):
20
+ ...
21
+
22
+ def get_widget_by_id(self, widget_id: int) -> Dict:
23
+ return {}
24
+
25
+ def get_widgets(self, name: str, limit: int = 10, *, active: bool = True) -> list:
26
+ return []
27
+
28
+ @staticmethod
29
+ def get_default_widget() -> dict: # static -> skipped
30
+ return {}
31
+
32
+ def get_display_name(widget_dict: dict) -> str: # no self -> skipped
33
+ return ""
34
+
35
+ def get_test_widget_id(self) -> int: # test helper -> skipped
36
+ return 1
37
+
38
+ def _get_internal(self) -> None: # private -> skipped
39
+ ...
40
+
41
+ def get_widget_by_id(self, widget_id: int) -> Dict: # duplicate -> deduped
42
+ return {}
43
+
44
+ def insert_widget(self, widget_dict: dict) -> int: # write -> skipped
45
+ return 0
46
+ '''
47
+
48
+
49
+ def _generate_from(source: str) -> str:
50
+ with tempfile.TemporaryDirectory() as d:
51
+ path = os.path.join(d, "widgets_local.py")
52
+ with open(path, "w", encoding="utf-8") as f:
53
+ f.write(source)
54
+ return gen.generate(path)
55
+
56
+
57
+ def test_output_is_valid_python():
58
+ ast.parse(_generate_from(FIXTURE_SOURCE))
59
+
60
+
61
+ def test_wraps_only_read_only_instance_methods():
62
+ code = _generate_from(FIXTURE_SOURCE)
63
+ tree = ast.parse(code)
64
+ twins = {n.name for n in tree.body if isinstance(n, ast.FunctionDef) and n.name.endswith("_strand")}
65
+ assert twins == {"get_widget_by_id_strand", "get_widgets_strand"}
66
+
67
+
68
+ def test_skips_static_noself_testhelper_private_and_writes():
69
+ code = _generate_from(FIXTURE_SOURCE)
70
+ for skipped in (
71
+ "get_default_widget_strand", # static
72
+ "get_display_name_strand", # no self
73
+ "get_test_widget_id_strand", # test helper
74
+ "_get_internal_strand", # private
75
+ "insert_widget_strand", # write
76
+ ):
77
+ assert skipped not in code
78
+
79
+
80
+ def test_deduplicates_repeated_method():
81
+ code = _generate_from(FIXTURE_SOURCE)
82
+ assert code.count("def get_widget_by_id_strand(") == 1
83
+
84
+
85
+ def test_factory_and_class_import():
86
+ code = _generate_from(FIXTURE_SOURCE)
87
+ assert "from .widgets_local import WidgetsLocal" in code
88
+ assert "def _widgets() -> WidgetsLocal:" in code
89
+ assert "return WidgetsLocal()" in code
90
+
91
+
92
+ def test_carries_over_annotation_imports():
93
+ # get_widget_by_id returns Dict, so the typing import must come along.
94
+ code = _generate_from(FIXTURE_SOURCE)
95
+ assert "from typing import Dict" in code
96
+
97
+
98
+ def test_twin_body_calls_same_named_method_and_returns():
99
+ code = _generate_from(FIXTURE_SOURCE)
100
+ assert "get_widget_by_id_strand_result = _widgets().get_widget_by_id(widget_id)" in code
101
+ assert "return get_widget_by_id_strand_result" in code
102
+
103
+
104
+ def test_keyword_only_and_default_args_preserved():
105
+ code = _generate_from(FIXTURE_SOURCE)
106
+ assert "def get_widgets_strand(name: str, limit: int = 10, active: bool = True) -> list:" in code
107
+ assert "_widgets().get_widgets(name, limit, active=active)" in code
@@ -0,0 +1,132 @@
1
+ """Tests for the Ollama weather agent.
2
+
3
+ The NWS calls live in the `get_forecast` tool, so the important behavior can be
4
+ tested fast and deterministically by mocking `requests` — no network or LLM
5
+ needed. The live test at the bottom is skipped by default; it exercises the full
6
+ Ollama -> tool -> answer path and needs Ollama running plus internet.
7
+ """
8
+
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ import pytest
12
+
13
+ import weather_agent
14
+
15
+
16
+ def test_weather_agent_is_built():
17
+ """The module exposes a constructed agent, its tool, and a system prompt."""
18
+ assert weather_agent.weather_agent is not None
19
+ assert weather_agent.get_forecast is not None
20
+ assert isinstance(weather_agent.WEATHER_SYSTEM_PROMPT, str)
21
+ assert weather_agent.WEATHER_SYSTEM_PROMPT.strip() != ""
22
+
23
+
24
+ def _mock_response(status_code, payload):
25
+ response = MagicMock(status_code=status_code)
26
+ response.json.return_value = payload
27
+ return response
28
+
29
+
30
+ def test_get_forecast_formats_period():
31
+ """A valid location returns a one-line forecast with temperature + conditions."""
32
+ points = _mock_response(200, {
33
+ "properties": {"forecast": "https://api.weather.gov/gridpoints/SEW/124,67/forecast"},
34
+ })
35
+ forecast = _mock_response(200, {
36
+ "properties": {"periods": [{
37
+ "name": "Tonight",
38
+ "temperature": 53,
39
+ "temperatureUnit": "F",
40
+ "shortForecast": "Mostly Cloudy",
41
+ "windSpeed": "8 mph",
42
+ "windDirection": "SSW",
43
+ }]},
44
+ })
45
+
46
+ # get_forecast makes two GETs: points, then the forecast URL.
47
+ with patch("weather_agent.requests.get", side_effect=[points, forecast]):
48
+ answer = weather_agent.get_forecast(47.6062, -122.3321)
49
+
50
+ assert "53°F" in answer
51
+ assert "Mostly Cloudy" in answer
52
+
53
+
54
+ def test_get_forecast_outside_us_is_refused():
55
+ """Negative case: a non-US / unknown location is refused, not invented.
56
+
57
+ The NWS only covers the US and returns a non-200 for anything else, so the
58
+ tool must say it has no data — and must NOT fabricate a temperature.
59
+ """
60
+ with patch("weather_agent.requests.get", return_value=_mock_response(404, {})):
61
+ answer = weather_agent.get_forecast(0, 0)
62
+
63
+ assert "United States" in answer
64
+ assert "°F" not in answer
65
+
66
+
67
+ def test_available_ollama_models_parses_client_response():
68
+ """Model ids are extracted from the ollama client's `.models[].model`."""
69
+ item = MagicMock()
70
+ item.model = "qwen2.5:7b"
71
+ response = MagicMock(models=[item])
72
+
73
+ with patch("weather_agent.ollama.list", return_value=response):
74
+ assert weather_agent.available_ollama_models() == ["qwen2.5:7b"]
75
+
76
+
77
+ def test_available_ollama_models_empty_when_ollama_down():
78
+ """If the ollama client raises (server down), we return [] to fall back."""
79
+ with patch("weather_agent.ollama.list", side_effect=ConnectionError):
80
+ assert weather_agent.available_ollama_models() == []
81
+
82
+
83
+ def test_choose_model_non_interactive_uses_default_without_prompting():
84
+ """In tests / CI (stdin not a tty) the configured default is used, no input()."""
85
+ with patch("weather_agent.available_ollama_models", return_value=["a", "b"]), \
86
+ patch("weather_agent.sys.stdin.isatty", return_value=False), \
87
+ patch("weather_agent.input", side_effect=AssertionError("must not prompt")):
88
+ assert weather_agent.choose_model("b") == "b"
89
+
90
+
91
+ def test_choose_model_picks_by_number():
92
+ """A valid number selects the matching model from the listed order."""
93
+ with patch("weather_agent.available_ollama_models", return_value=["a", "b", "c"]), \
94
+ patch("weather_agent.sys.stdin.isatty", return_value=True), \
95
+ patch("weather_agent.input", return_value="3"):
96
+ assert weather_agent.choose_model("a") == "c"
97
+
98
+
99
+ def test_choose_model_invalid_choice_falls_back_to_default():
100
+ """Empty / out-of-range / garbage input falls back to the default model."""
101
+ with patch("weather_agent.available_ollama_models", return_value=["a", "b"]), \
102
+ patch("weather_agent.sys.stdin.isatty", return_value=True), \
103
+ patch("weather_agent.input", return_value="99"):
104
+ assert weather_agent.choose_model("a") == "a"
105
+
106
+
107
+ def test_choose_model_default_not_installed_notes_and_uses_first(capsys):
108
+ """If the configured default isn't installed, note it and fall back to the
109
+ first listed model (pressing Enter selects that fallback)."""
110
+ with patch("weather_agent.available_ollama_models", return_value=["a", "b"]), \
111
+ patch("weather_agent.sys.stdin.isatty", return_value=True), \
112
+ patch("weather_agent.input", return_value=""):
113
+ assert weather_agent.choose_model("not-installed") == "a"
114
+
115
+ out = capsys.readouterr().out
116
+ assert "not-installed" in out and "not installed" in out
117
+
118
+
119
+ @pytest.mark.skip(reason="live test: needs Ollama running + internet to api.weather.gov")
120
+ def test_weather_agent_answers_live():
121
+ """End-to-end: ask for a US city and expect a weather-shaped answer.
122
+
123
+ The LLM is non-deterministic, so we assert the answer is non-empty AND
124
+ mentions a weather concept rather than checking exact wording.
125
+ """
126
+ weather_terms = ("°f", "temperature", "forecast", "cloudy", "rain", "sunny", "wind")
127
+ answer = str(weather_agent.weather_agent("What's the weather like in Seattle?")).strip().lower()
128
+
129
+ assert answer != ""
130
+ assert any(term in answer for term in weather_terms), (
131
+ f"answer did not look weather-related: {answer!r}"
132
+ )
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: artificial-intelligence-local
3
+ Version: 0.0.1b3046
4
+ Summary: PyPI artificial-intelligence-local Python Package owned by Circlez.ai
5
+ Home-page: https://github.com/circles-zone/artificial-intelligence-local-python-package
6
+ Author: Circles
7
+ Author-email: info@circlez.ai
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: python-sdk-remote
12
+ Requires-Dist: logger-local
13
+ Requires-Dist: strands-agents
14
+ Requires-Dist: ollama
15
+ Requires-Dist: python-dotenv
16
+ Requires-Dist: requests
17
+ Dynamic: author
18
+ Dynamic: author-email
19
+ Dynamic: classifier
20
+ Dynamic: description
21
+ Dynamic: description-content-type
22
+ Dynamic: home-page
23
+ Dynamic: requires-dist
24
+ Dynamic: summary
25
+
26
+ PyPI artificial-intelligence-local Python Package owned by Circlez.ai
27
+ JIRA Work Item: https://circles-zone.atlassian.net/browse/BU-3046
28
+ GHA: https://github.com/circles-zone/artificial-intelligence-local-python-package/actions
@@ -0,0 +1,14 @@
1
+ artificial_intelligence_local/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ artificial_intelligence_local/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ artificial_intelligence_local/src/contact_agent.py,sha256=KJbGUz-BqWpOCK8aTMI_Gg1r-5OHeY03xJ7zXmxSkVc,216
4
+ artificial_intelligence_local/src/strands_wrapper_generator.py,sha256=pTjZOMx7m3gNeMRqRZT-3hY2tmk5WipPTtx7rjGj1Oc,8901
5
+ artificial_intelligence_local/src/weather_agent.py,sha256=19NwPhshXMPirCZcM76rJTwzW-w2HNGBhldJDPa-8aE,8553
6
+ artificial_intelligence_local/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ artificial_intelligence_local/tests/artificial_intelligence_local_test.py,sha256=howtqD2rHlK9Zg08JnyPmZgXz-d4o83Aikti8kQIlL4,344
8
+ artificial_intelligence_local/tests/contact_agent_test.py,sha256=erit-d8IS60Auj1S4zY1QPoOMj_nGspCT2eDloyomg4,58
9
+ artificial_intelligence_local/tests/strands_wrapper_generator_test.py,sha256=nnGmzE2PrUYgv-I6TYhxwJz7qy2zQE9Jk9yR1VrxrWI,3390
10
+ artificial_intelligence_local/tests/weather_agent_test.py,sha256=iWmUMUsBYtycuVqhxr13obRd2Kzkhs5OeBuv2cpFUnE,5492
11
+ artificial_intelligence_local-0.0.1b3046.dist-info/METADATA,sha256=UxyPy7-E1IwQTtHzCzeEN_f-KjlfT_R7FOQRH_ck31E,991
12
+ artificial_intelligence_local-0.0.1b3046.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ artificial_intelligence_local-0.0.1b3046.dist-info/top_level.txt,sha256=QAJP8cTE3SKcbE58zw4EH_qDgZqVNktdTyEemkzGXoI,30
14
+ artificial_intelligence_local-0.0.1b3046.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ artificial_intelligence_local