artificial-intelligence-local 0.0.1b3046__tar.gz

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.
Files changed (20) hide show
  1. artificial_intelligence_local-0.0.1b3046/PKG-INFO +28 -0
  2. artificial_intelligence_local-0.0.1b3046/README.md +99 -0
  3. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/__init__.py +0 -0
  4. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/src/__init__.py +0 -0
  5. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/src/contact_agent.py +6 -0
  6. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/src/strands_wrapper_generator.py +239 -0
  7. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/src/weather_agent.py +212 -0
  8. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/__init__.py +0 -0
  9. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/artificial_intelligence_local_test.py +10 -0
  10. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/contact_agent_test.py +1 -0
  11. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/strands_wrapper_generator_test.py +107 -0
  12. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/weather_agent_test.py +132 -0
  13. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/PKG-INFO +28 -0
  14. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/SOURCES.txt +18 -0
  15. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/dependency_links.txt +1 -0
  16. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/requires.txt +6 -0
  17. artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/top_level.txt +1 -0
  18. artificial_intelligence_local-0.0.1b3046/pyproject.toml +29 -0
  19. artificial_intelligence_local-0.0.1b3046/setup.cfg +4 -0
  20. artificial_intelligence_local-0.0.1b3046/setup.py +79 -0
@@ -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,99 @@
1
+ # General
2
+
3
+ Unlike the README.md in the root directory, this is a README.md of this specific repo/package<br>
4
+
5
+ TODO Please update this README.md of this specific repo/package and not the README.md in the root directory based on the python-package-template repo<br>
6
+
7
+ ## TODOs
8
+
9
+ ### Implement using AWS Strands Agents SDK wrapper classes<br>
10
+
11
+ All the classes below should get an optional parameter of OurAgentSdkEnum, which at this point has only one value: AWS_STRANDS_AGENTS_SDK<br>
12
+
13
+ TODO Create OurLlmModel class, which at this point inherits "stands.models.litellm" LiteLLMModel. Has model_id data member<br>
14
+ TODO Create OurLlmModelWithSystemPrompt class which inherits OurLlmModel and has system_prompt data member<br>
15
+ TODO Create OurLlmPrompt class, which prompt str data member<br>
16
+ TODO Create OurLlmComplete method<br>
17
+ TODO Create OurLlmResponse class with which the same data memresponse str data member<br>
18
+ TODO Create OurLlmConversation class which includes Context<br>
19
+
20
+ TODO Create OurLlmAgent class with inheritance at this point stands Agent<br>
21
+ TODO Create OurLlmSession class, which at this point inherits strands.session.s3_session_manager S3SessionManager (it can recieve optional OurStorage parameter i.e. S3) uses our storage-local-python-package repo<br>
22
+ TODO Create OurLlmMemory (knowledgebase) Class which uses SmartVectordb class from our smart-datastore-python-package repo<br>
23
+ TODO Create OurLlmGuardrails class that imports strands.models BedrockModel and uses BedrockModel( guardrail_id and guardrail_version and guardrail_trace )<br>
24
+ TODO Create OurLlmMcpClient, which at this point inherits strands.tools.mcp MCPClient<br>
25
+
26
+ TODO Natural 2 KQL
27
+ TODO Natural 2 SQL
28
+
29
+ TODO Convert https://github.com/gilinachum/agents/blob/main/strands-beginner.ipynb to use OurLlmModel, OurLlmPrompt (prompt-local-python-package), OurLlmResponse, OurLlmConversation, OurLlmAgent, OurLlmSession, OurLlmMemory, OurLlmGuardrails in this repo<br>
30
+ TODO Implement https://github.com/microsoft/langchain-for-beginners in prompt-local-python-package using inheritance to allow also other implementations.<br>
31
+
32
+ TODO Develop OurLlmRouter based on OpenRouter, AWS Bedrock
33
+
34
+ TODO Wrapper around OpenRouter to gain API for Any AI Model
35
+
36
+ ### Implement using OpenAI's Agent SDK
37
+
38
+ You can start by working with an OpenAI-compatible model running locally with LMStudio running locally<br>
39
+
40
+ ### Implement using Google's Agent Development Kit (ADK)
41
+
42
+ ### Implement using LangChain Agents
43
+
44
+ ) using machine learning model, Smart Vector Store (smart_vectorstore.py)<br>
45
+
46
+ # Running the local weather agent
47
+
48
+ An example agent lives at `artificial_intelligence_local/src/weather_agent.py`. It runs a local
49
+ LLM via [Ollama](https://ollama.com) and answers US weather questions from the National Weather
50
+ Service API.
51
+
52
+ Following the tool-based pattern in the [Strands samples](https://github.com/strands-agents/samples)
53
+ repo, the National Weather Service two-step call (`points` → `forecast`) lives inside a Python
54
+ `get_forecast(latitude, longitude)` tool. The model's only job is to pick the location's
55
+ coordinates, call the tool, and phrase the one-line answer — which is far more reliable on small
56
+ local models than asking the model to build API URLs and parse JSON itself.
57
+
58
+ ## Prerequisites
59
+
60
+ 1. Install Ollama: https://ollama.com/download — then start it (the Ollama app, or `ollama serve`).
61
+ 2. Pull a model that supports tool-calling, e.g.: `ollama pull qwen2.5:7b`
62
+ 3. Copy `.env.local1.example` to `.env.local1` and set `OLLAMA_HOST` / `OLLAMA_MODEL_ID` for your machine.
63
+
64
+ ## Run
65
+
66
+ Run these from the package directory. Note the repo nests a same-named folder, so from the
67
+ cloned repository root you first need to `cd` one level in:
68
+
69
+ ```bash
70
+ # From the repository root:
71
+ cd artificial-intelligence-local-python-package # the inner package dir (skip if already there)
72
+
73
+ python -m venv .venv && source .venv/bin/activate
74
+ pip install -r requirements.txt
75
+ python artificial_intelligence_local/src/weather_agent.py
76
+ ```
77
+
78
+ On startup the agent lists the Ollama models you have installed and lets you pick one by number
79
+ (press Enter to accept the default). Then ask, e.g., `What's the weather like in Seattle?` (type
80
+ `exit` to quit).
81
+
82
+ The pre-selected default comes from `.env.local1` (`OLLAMA_MODEL_ID`). `qwen2.5:7b` is the default
83
+ and handles the coordinate-lookup + tool-call reliably; smaller models like `llama3.2:3b` also work
84
+ but are more likely to guess coordinates poorly. In non-interactive runs (tests / CI) the default is
85
+ used without prompting.
86
+
87
+ # Versions
88
+
89
+ [pub] 0.0.1 Initial version (change the directories, files, setup.py, .github/workflows/*.yml)<br>
90
+
91
+ # Installing instructions
92
+
93
+ It's advised to use venv<br>
94
+ If you have Windows, it's also advised to use Windows Subsystem for Linux<br>
95
+
96
+ ## Install dependencies
97
+
98
+ Run ./download-beta-sdk.sh from the bash terminal<br>
99
+ Run pip install -r requirements.txt from the bash terminal<br>
@@ -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.")
@@ -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,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ artificial_intelligence_local/__init__.py
5
+ artificial_intelligence_local.egg-info/PKG-INFO
6
+ artificial_intelligence_local.egg-info/SOURCES.txt
7
+ artificial_intelligence_local.egg-info/dependency_links.txt
8
+ artificial_intelligence_local.egg-info/requires.txt
9
+ artificial_intelligence_local.egg-info/top_level.txt
10
+ artificial_intelligence_local/src/__init__.py
11
+ artificial_intelligence_local/src/contact_agent.py
12
+ artificial_intelligence_local/src/strands_wrapper_generator.py
13
+ artificial_intelligence_local/src/weather_agent.py
14
+ artificial_intelligence_local/tests/__init__.py
15
+ artificial_intelligence_local/tests/artificial_intelligence_local_test.py
16
+ artificial_intelligence_local/tests/contact_agent_test.py
17
+ artificial_intelligence_local/tests/strands_wrapper_generator_test.py
18
+ artificial_intelligence_local/tests/weather_agent_test.py
@@ -0,0 +1,6 @@
1
+ python-sdk-remote
2
+ logger-local
3
+ strands-agents
4
+ ollama
5
+ python-dotenv
6
+ requests
@@ -0,0 +1,29 @@
1
+ # This file should be in the future instead of setup.py
2
+ # https://python-poetry.org/docs/pyproject
3
+ # https://stackoverflow.com/questions/78048223/adding-folder-with-data-with-pyproject-toml
4
+ # https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license
5
+
6
+ # This file is mandatory for the `poetry version patch`
7
+
8
+ # It seems we need to copy this file also to serverless-com repo, as required by dialog-workflow-python-package
9
+
10
+ [build-system]
11
+ requires = ["setuptools>=61.0"]
12
+ build-backend = "setuptools.build_meta"
13
+
14
+ [tool.pytest.ini_options]
15
+ pythonpath = ["."]
16
+
17
+ [tool.poetry]
18
+ name = "artificial-intelligence-local"
19
+ # I believe we are still using the version from setup.py and not from here until Potery will work
20
+ version = "0.0.1" # https://pypi.org/project/artificial-intelligence-local/
21
+ description = "artificial-intelligence-local Python Package"
22
+ readme = "README.md"
23
+ authors = [
24
+ "Circlez.ai <info@circlez.ai>",
25
+ ]
26
+
27
+ [tool.poetry.dev-dependencies]
28
+ pytest = "^8.0"
29
+ pytest-cov = "^5.0"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,79 @@
1
+ # serverless.com repo "python -m build" works without setup.py
2
+ # Should update package_dir if not using our recommended directory structure
3
+
4
+ import setuptools
5
+
6
+ # TODO: Change the PACKAGE_NAME to the package's name - Either xxx-local or
7
+ # xxx-remote (without the -python-package suffix). Only lowercase, no
8
+ # underlines.
9
+ # Used by pypa/gh-action-pypi-publish
10
+ # Package Name should be identical to the inner directory name
11
+ # Changing the package name here, will cause a change in the package directory
12
+ # name as well
13
+ # PACKAGE_NAME should be singular if handling only one instance
14
+ # PACKAGE_NAME should not include the word "main"
15
+
16
+ # e.g.: queue-local, without python-package suffix
17
+ PACKAGE_NAME = "artificial-intelligence-local"
18
+
19
+ package_dir = PACKAGE_NAME.replace("-", "_")
20
+ # If we need backward-compatible:
21
+ # old_package_dir = "old_package_name"
22
+
23
+ PACKAGE_DESCRIPTION = f"PyPI {PACKAGE_NAME} Python Package owned by Circlez.ai"
24
+
25
+ setuptools.setup(
26
+ name=PACKAGE_NAME,
27
+ # Increase this number every time you make a change you
28
+ # want to publish. After 0.0.9 switch to 0.0.10 and not 0.1.0
29
+ # version can't be 0.0.
30
+ # If you find yourself increasing the version in more than one patch number (it might indicate there are multiple branches which you should submit their PRs before your PR) # noqa: E501
31
+ # Jira Work Item number appended after the version i.e., 0.0.1b2432
32
+ version='0.0.1b3046', # https://pypi.org/project/artificial-intelligence-local/
33
+ author="Circles",
34
+ author_email="info@circlez.ai",
35
+ description=f"{PACKAGE_DESCRIPTION}",
36
+ long_description="\n".join([
37
+ PACKAGE_DESCRIPTION,
38
+ "JIRA Work Item: https://circles-zone.atlassian.net/browse/BU-3046",
39
+ f"GHA: https://github.com/circles-zone/{PACKAGE_NAME}-python-package/actions",
40
+ ]),
41
+ long_description_content_type='text/markdown',
42
+ url=f"https://github.com/circles-zone/{PACKAGE_NAME}-python-package",
43
+ # packages=[package_dir],
44
+ # packages=[package_dir, old_package_dir],
45
+ packages=setuptools.find_packages(),
46
+
47
+ # I'm not sure we need package_dir
48
+ # package_dir={package_dir: f'{package_dir}/src'},
49
+ # TODO Unfortunately in event-main-local-restapi there are no
50
+ # repo-directory and no package directory (flat directory structure)
51
+ # package_dir={package_dir: f'src'},
52
+
53
+ # package_dir={package_dir: f'{package_dir}/src',
54
+ # old_package_dir: f'{package_dir}/src'},
55
+ package_data={package_dir: ['*.py']},
56
+ classifiers=[
57
+ "Programming Language :: Python :: 3",
58
+ # https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license
59
+ # TODO Fix all repos, especially the Python templates
60
+ # "License :: MIT AND (Apache-2.0 OR BSD-2-Clause)",
61
+ "Operating System :: OS Independent",
62
+ ],
63
+ # Do not add packages needed only for tests i.e. packages for get_test_xxx(
64
+ # TODO: Update which packages to include with this package in production
65
+ # (dependencies) - Not for development/testing
66
+ install_requires=[
67
+ 'python-sdk-remote',
68
+ # TODO: in -remote package, please use logger-remote instead.
69
+ 'logger-local',
70
+ # AI agent stack
71
+ 'strands-agents',
72
+ 'ollama',
73
+ 'python-dotenv',
74
+ 'requests',
75
+ # TODO: In -local package please uncomment the bellow line. In -remote packages please delete this line. # noqa E501
76
+ # 'smart-datastore-local',
77
+ # 'logzio-python-handler>=4.1.2', # https://pypi.org/project/logzio-python-handler/ # noqa E501
78
+ ]
79
+ )