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.
- artificial_intelligence_local/__init__.py +0 -0
- artificial_intelligence_local/src/__init__.py +0 -0
- artificial_intelligence_local/src/contact_agent.py +6 -0
- artificial_intelligence_local/src/strands_wrapper_generator.py +239 -0
- artificial_intelligence_local/src/weather_agent.py +212 -0
- artificial_intelligence_local/tests/__init__.py +0 -0
- artificial_intelligence_local/tests/artificial_intelligence_local_test.py +10 -0
- artificial_intelligence_local/tests/contact_agent_test.py +1 -0
- artificial_intelligence_local/tests/strands_wrapper_generator_test.py +107 -0
- artificial_intelligence_local/tests/weather_agent_test.py +132 -0
- artificial_intelligence_local-0.0.1b3046.dist-info/METADATA +28 -0
- artificial_intelligence_local-0.0.1b3046.dist-info/RECORD +14 -0
- artificial_intelligence_local-0.0.1b3046.dist-info/WHEEL +5 -0
- artificial_intelligence_local-0.0.1b3046.dist-info/top_level.txt +1 -0
|
File without changes
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
artificial_intelligence_local
|