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.
- artificial_intelligence_local-0.0.1b3046/PKG-INFO +28 -0
- artificial_intelligence_local-0.0.1b3046/README.md +99 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/__init__.py +0 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/src/__init__.py +0 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/src/contact_agent.py +6 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/src/strands_wrapper_generator.py +239 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/src/weather_agent.py +212 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/__init__.py +0 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/artificial_intelligence_local_test.py +10 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/contact_agent_test.py +1 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/strands_wrapper_generator_test.py +107 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/weather_agent_test.py +132 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/PKG-INFO +28 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/SOURCES.txt +18 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/dependency_links.txt +1 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/requires.txt +6 -0
- artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/top_level.txt +1 -0
- artificial_intelligence_local-0.0.1b3046/pyproject.toml +29 -0
- artificial_intelligence_local-0.0.1b3046/setup.cfg +4 -0
- 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>
|
|
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
|
artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/contact_agent_test.py
ADDED
|
@@ -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
|
artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local/tests/weather_agent_test.py
ADDED
|
@@ -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
|
artificial_intelligence_local-0.0.1b3046/artificial_intelligence_local.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
artificial_intelligence_local
|
|
@@ -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,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
|
+
)
|