artificial-intelligence-local 0.0.2b3115__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- artificial_intelligence_local/__init__.py +0 -0
- artificial_intelligence_local/src/__init__.py +0 -0
- artificial_intelligence_local/src/contact_agent.py +264 -0
- artificial_intelligence_local/src/ollama_agent_base.py +204 -0
- artificial_intelligence_local/src/weather_agent.py +112 -0
- artificial_intelligence_local/tests/__init__.py +0 -0
- artificial_intelligence_local/tests/artificial_intelligence_local_test.py +10 -0
- artificial_intelligence_local/tests/contact_agent_test.py +275 -0
- artificial_intelligence_local/tests/ollama_agent_base_test.py +124 -0
- artificial_intelligence_local/tests/weather_agent_test.py +83 -0
- artificial_intelligence_local-0.0.2b3115.dist-info/METADATA +29 -0
- artificial_intelligence_local-0.0.2b3115.dist-info/RECORD +14 -0
- artificial_intelligence_local-0.0.2b3115.dist-info/WHEEL +5 -0
- artificial_intelligence_local-0.0.2b3115.dist-info/top_level.txt +1 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Ollama contacts agent.
|
|
2
|
+
|
|
3
|
+
A local-first Strands agent over our contacts, built on the same pattern as the
|
|
4
|
+
weather agent (see weather_agent.py): the data access lives in Python `@tool`
|
|
5
|
+
functions and a local Ollama model only picks the tool, fills in its arguments,
|
|
6
|
+
and phrases the answer.
|
|
7
|
+
|
|
8
|
+
The tools are thin wrappers over the read-only "_strand" twins published by
|
|
9
|
+
contact-local (contact_local/contact_local_strands.py, BU-3047). The `@tool`
|
|
10
|
+
decorator lives here rather than in contact-main on purpose: contact-main stays
|
|
11
|
+
free of any Strands dependency, and this package decides how the functions are
|
|
12
|
+
exposed to a model.
|
|
13
|
+
|
|
14
|
+
Two deliberate constraints shape the tools below:
|
|
15
|
+
|
|
16
|
+
1. Flat, scalar arguments only. The strand twins speak `contact_dict`, but a
|
|
17
|
+
dict is a terrible thing to ask a small local model to synthesize -- it will
|
|
18
|
+
invent keys. So no tool takes a dict: where an answer needs two strand calls
|
|
19
|
+
(fetch the contact, then read a field off it), the tool composes them
|
|
20
|
+
internally and the model just passes a contact id.
|
|
21
|
+
|
|
22
|
+
2. Lookup is by contact id, phone, or email -- NOT by name. ContactsLocal has no
|
|
23
|
+
name-based lookup (`get_existing_contact_dict` matches on phone/email only),
|
|
24
|
+
so the agent cannot answer "What's Tal's phone number?" and the system prompt
|
|
25
|
+
below tells it to say so instead of guessing. Name lookup needs
|
|
26
|
+
`get_contact_by_name` added to contact-main first (it also has to deal with
|
|
27
|
+
first+last names not being unique -- BU-3058).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from strands import tool
|
|
31
|
+
|
|
32
|
+
from ollama_agent_base import DEFAULT_OLLAMA_MODEL_ID, build_ollama_agent, run_chat_loop
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _strand_functions():
|
|
36
|
+
"""Return the contact-local strand functions, imported lazily.
|
|
37
|
+
|
|
38
|
+
Importing contact_local pulls in person_local, which logs in against the
|
|
39
|
+
local authentication server (http://localhost:3000) *at import time*.
|
|
40
|
+
Keeping that import inside the call means this module -- and its tests --
|
|
41
|
+
can be imported with no database and no auth server running; only an actual
|
|
42
|
+
lookup needs them. It is also the seam the tests mock.
|
|
43
|
+
"""
|
|
44
|
+
from contact_local import contact_local_strands
|
|
45
|
+
|
|
46
|
+
return contact_local_strands
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _lookup_failed(error: Exception) -> str:
|
|
50
|
+
"""The message a tool returns when the contacts backend could not be reached.
|
|
51
|
+
|
|
52
|
+
Kept short, and deliberately free of the underlying exception text. Passing
|
|
53
|
+
the raw error back (e.g. the auth server's 500, which is a wall of JSON) led
|
|
54
|
+
a small local model to compress it into "There is no contact with id 1" --
|
|
55
|
+
turning a backend outage into a confident, wrong "that person doesn't
|
|
56
|
+
exist". The wording below tells the model what the failure is NOT.
|
|
57
|
+
"""
|
|
58
|
+
return (
|
|
59
|
+
f"LOOKUP FAILED ({type(error).__name__}): the contacts database could not be reached. "
|
|
60
|
+
"This is a system error. It does NOT mean the contact does not exist -- "
|
|
61
|
+
"tell the user the contacts database is unavailable right now."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _format_contact(contact_dict: dict) -> str:
|
|
66
|
+
"""Describe who a contact is, for the model to read."""
|
|
67
|
+
name = " ".join(
|
|
68
|
+
part for part in (contact_dict.get("first_name"), contact_dict.get("last_name")) if part
|
|
69
|
+
)
|
|
70
|
+
description = name or contact_dict.get("display_as") or "Unnamed contact"
|
|
71
|
+
|
|
72
|
+
for field in ("job_title", "organization"):
|
|
73
|
+
value = contact_dict.get(field)
|
|
74
|
+
if value:
|
|
75
|
+
description += f", {value}"
|
|
76
|
+
return description
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@tool
|
|
80
|
+
def get_contact_by_id(contact_id: int) -> str:
|
|
81
|
+
"""Get who a contact is (name, job title, organization) from their contact id.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
contact_id: The numeric id of the contact, e.g. 42.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A short description of the contact, or a message saying no contact has
|
|
88
|
+
that id.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
contact_dict = _strand_functions().get_contact_by_contact_id_strand(contact_id)
|
|
92
|
+
except Exception as error: # noqa: BLE001 - report the failure to the model, never raise at it
|
|
93
|
+
return _lookup_failed(error)
|
|
94
|
+
|
|
95
|
+
if not contact_dict:
|
|
96
|
+
return f"There is no contact with id {contact_id}."
|
|
97
|
+
return f"Contact {contact_id}: {_format_contact(contact_dict)}."
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@tool
|
|
101
|
+
def get_contact_phone_numbers(contact_id: int) -> str:
|
|
102
|
+
"""Get the phone numbers of the contact with this contact id.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
contact_id: The numeric id of the contact, e.g. 42.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The contact's phone numbers, or a message saying they have none on
|
|
109
|
+
record / that no contact has that id.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
strands = _strand_functions()
|
|
113
|
+
contact_dict = strands.get_contact_by_contact_id_strand(contact_id)
|
|
114
|
+
if not contact_dict:
|
|
115
|
+
return f"There is no contact with id {contact_id}."
|
|
116
|
+
phone_numbers = strands.get_contact_phone_numbers_from_contact_dict_strand(contact_dict)
|
|
117
|
+
except Exception as error: # noqa: BLE001 - report the failure to the model, never raise at it
|
|
118
|
+
return _lookup_failed(error)
|
|
119
|
+
|
|
120
|
+
if not phone_numbers:
|
|
121
|
+
return f"{_format_contact(contact_dict)} has no phone number on record."
|
|
122
|
+
return f"{_format_contact(contact_dict)}: {', '.join(phone_numbers)}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@tool
|
|
126
|
+
def get_contact_email_addresses(contact_id: int) -> str:
|
|
127
|
+
"""Get the email addresses of the contact with this contact id.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
contact_id: The numeric id of the contact, e.g. 42.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The contact's email addresses, or a message saying they have none on
|
|
134
|
+
record / that no contact has that id.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
strands = _strand_functions()
|
|
138
|
+
contact_dict = strands.get_contact_by_contact_id_strand(contact_id)
|
|
139
|
+
if not contact_dict:
|
|
140
|
+
return f"There is no contact with id {contact_id}."
|
|
141
|
+
email_addresses = strands.get_contact_email_addresses_from_contact_dict_strand(contact_dict)
|
|
142
|
+
except Exception as error: # noqa: BLE001 - report the failure to the model, never raise at it
|
|
143
|
+
return _lookup_failed(error)
|
|
144
|
+
|
|
145
|
+
if not email_addresses:
|
|
146
|
+
return f"{_format_contact(contact_dict)} has no email address on record."
|
|
147
|
+
return f"{_format_contact(contact_dict)}: {', '.join(email_addresses)}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@tool
|
|
151
|
+
def find_contact_by_phone_or_email(phone_number: str = "", email_address: str = "") -> str:
|
|
152
|
+
"""Check whether we already have a contact with this phone number or email address.
|
|
153
|
+
|
|
154
|
+
Use this to answer questions like "do we already know someone with this
|
|
155
|
+
email?". Give a phone number, an email address, or both.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
phone_number: A phone number to search for, e.g. "+972501234567".
|
|
159
|
+
email_address: An email address to search for, e.g. "tal@circ.zone".
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
A description of the matching contact including its contact id (which
|
|
163
|
+
can then be used with the other tools), or a message saying nobody
|
|
164
|
+
matches.
|
|
165
|
+
"""
|
|
166
|
+
if not phone_number and not email_address:
|
|
167
|
+
return "Give me a phone number or an email address to search for."
|
|
168
|
+
|
|
169
|
+
# The strand twin matches on the phone/email columns of a contact_dict, so we
|
|
170
|
+
# compose that dict here -- the model only ever passes flat strings.
|
|
171
|
+
search_contact_dict = {}
|
|
172
|
+
if phone_number:
|
|
173
|
+
search_contact_dict["phone1"] = phone_number
|
|
174
|
+
if email_address:
|
|
175
|
+
search_contact_dict["email1"] = email_address
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
contact_dict = _strand_functions().get_existing_contact_dict_strand(search_contact_dict)
|
|
179
|
+
except Exception as error: # noqa: BLE001 - report the failure to the model, never raise at it
|
|
180
|
+
return _lookup_failed(error)
|
|
181
|
+
|
|
182
|
+
if not contact_dict:
|
|
183
|
+
searched = " or ".join(value for value in (phone_number, email_address) if value)
|
|
184
|
+
return f"We have no contact with {searched}."
|
|
185
|
+
|
|
186
|
+
contact_id = contact_dict.get("contact_id")
|
|
187
|
+
return f"Yes -- contact {contact_id}: {_format_contact(contact_dict)}."
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# The prompt does not list the tools: Strands already sends the model each tool's name,
|
|
191
|
+
# signature and docstring, so a list here would be a second copy that goes stale the
|
|
192
|
+
# moment a tool is added. Describe a tool in its docstring, not here.
|
|
193
|
+
#
|
|
194
|
+
# The name rule below is the exception, and it is deliberate. A tool schema can only say
|
|
195
|
+
# what EXISTS -- an absence is not expressible in any schema, so the model cannot infer
|
|
196
|
+
# it. Measured on qwen2.5:7b with "What's Tal's phone number?": with the explicit rule,
|
|
197
|
+
# 6/6 clean refusals; replacing it with a general "ask for an identifier you can look up",
|
|
198
|
+
# 0/6 -- the model reaches for the tool named find_contact_by_phone_or_email, gets
|
|
199
|
+
# nothing, then guesses contact_id=1 and reports a stranger's phone number as Tal's.
|
|
200
|
+
#
|
|
201
|
+
# TODO (when name lookup lands -- BU-3058): DELETE this rule, do not just edit it. It does
|
|
202
|
+
# not merely go stale, it SUPPRESSES the new tool: measured with a working name tool
|
|
203
|
+
# registered AND this rule still in the prompt, the model called no tool at all and told
|
|
204
|
+
# the user it cannot look people up by name. Tests stay green while the feature silently
|
|
205
|
+
# does nothing. The general prompt is fine once the gap itself is a registered tool -- a
|
|
206
|
+
# find_contact_by_name whose body reports "name lookup unavailable" scored 6/6 with no
|
|
207
|
+
# name rule in the prompt at all. That is the shape to move to once the tools are complete.
|
|
208
|
+
CONTACT_SYSTEM_PROMPT = """You are an assistant that answers questions about our contacts database.
|
|
209
|
+
|
|
210
|
+
Rules -- follow them strictly:
|
|
211
|
+
- Answer ONLY from what a tool returned. Never invent a name, phone number, email
|
|
212
|
+
address or contact id, and never guess an id to "try".
|
|
213
|
+
- You CANNOT look up a contact by name -- there is no tool for it. If you are asked
|
|
214
|
+
something like "What is Tal's phone number?", do not guess: say you can only look
|
|
215
|
+
someone up by contact id, phone number, or email address, and ask for one of those.
|
|
216
|
+
- If a tool says there is no such contact, say exactly that. Do not retry with a
|
|
217
|
+
different id.
|
|
218
|
+
- If a tool answer starts with "LOOKUP FAILED", the contacts database is broken or
|
|
219
|
+
unreachable. Say that the contacts database is unavailable right now. NEVER report
|
|
220
|
+
this as the contact not existing -- a failed lookup tells you NOTHING about whether
|
|
221
|
+
the contact exists.
|
|
222
|
+
- Do not narrate your steps or say what you are "about to" do. Call the tool, then
|
|
223
|
+
give only the final answer, in one or two short sentences.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def build_agent(model_id=DEFAULT_OLLAMA_MODEL_ID):
|
|
228
|
+
"""Build the contacts Agent backed by a local Ollama model.
|
|
229
|
+
|
|
230
|
+
Kept as a factory so the interactive picker can build the agent with the
|
|
231
|
+
model the user chose at startup, while imports/tests still get a ready-made
|
|
232
|
+
agent from the configured default (see ``contact_agent`` below).
|
|
233
|
+
"""
|
|
234
|
+
return build_ollama_agent(
|
|
235
|
+
model_id=model_id,
|
|
236
|
+
system_prompt=CONTACT_SYSTEM_PROMPT,
|
|
237
|
+
tools=[
|
|
238
|
+
get_contact_by_id,
|
|
239
|
+
get_contact_phone_numbers,
|
|
240
|
+
get_contact_email_addresses,
|
|
241
|
+
find_contact_by_phone_or_email,
|
|
242
|
+
],
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# A ready-made agent on the configured default, so importing the module (and the
|
|
247
|
+
# tests) get a working agent without any interactive prompt. Building the agent
|
|
248
|
+
# touches neither the database nor Ollama -- see _strand_functions.
|
|
249
|
+
contact_agent = build_agent()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Interactive loop so we can test it from the terminal. Needs the database and the
|
|
253
|
+
# local authentication server (http://localhost:3000) running, since every tool
|
|
254
|
+
# call goes through contact-local.
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
run_chat_loop(
|
|
257
|
+
build_agent=build_agent,
|
|
258
|
+
title="Ollama Contact Agent",
|
|
259
|
+
examples=[
|
|
260
|
+
"Who is contact 1?",
|
|
261
|
+
"What's the phone number of contact 1?",
|
|
262
|
+
"Do we already have someone with the email tal@circ.zone?",
|
|
263
|
+
],
|
|
264
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Shared Ollama + Strands plumbing for the local agents in this package.
|
|
2
|
+
|
|
3
|
+
The weather agent and the contact agent have the same shape: a local Ollama
|
|
4
|
+
model plus a few Python `@tool` functions. Everything that is not specific to
|
|
5
|
+
one agent -- building the Strands Agent on an OllamaModel, listing the models
|
|
6
|
+
you have pulled, picking one at startup, and the terminal chat loop -- lives
|
|
7
|
+
here, so an agent module only declares its tools and its system prompt.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
import ollama
|
|
14
|
+
from dotenv import find_dotenv, load_dotenv
|
|
15
|
+
from strands import Agent
|
|
16
|
+
from strands.models.ollama import OllamaModel
|
|
17
|
+
|
|
18
|
+
# Load OLLAMA_* settings from a local .env.local1 file if one exists
|
|
19
|
+
# (see .env.local1.example). find_dotenv searches up from the current working
|
|
20
|
+
# directory, so this works whichever sub-directory you launch an agent from.
|
|
21
|
+
load_dotenv(find_dotenv(".env.local1", usecwd=True))
|
|
22
|
+
|
|
23
|
+
# The model (the "brain"): a local Ollama model instead of a cloud provider.
|
|
24
|
+
# You must have Ollama installed and the model pulled first:
|
|
25
|
+
# `ollama pull <model_id>`. Running an agent interactively lets you pick a
|
|
26
|
+
# different installed model at startup (see choose_model).
|
|
27
|
+
DEFAULT_OLLAMA_MODEL_ID = os.getenv("OLLAMA_MODEL_ID", "qwen2.5:7b")
|
|
28
|
+
DEFAULT_OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_ollama_agent(model_id, system_prompt, tools):
|
|
32
|
+
"""Build a Strands Agent backed by a local Ollama model.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
model_id: An Ollama model you have pulled, e.g. "qwen2.5:7b".
|
|
36
|
+
system_prompt: The agent's instructions.
|
|
37
|
+
tools: The `@tool`-decorated functions the agent is allowed to call.
|
|
38
|
+
"""
|
|
39
|
+
model = OllamaModel(host=DEFAULT_OLLAMA_HOST, model_id=model_id)
|
|
40
|
+
return Agent(model=model, system_prompt=system_prompt, tools=tools)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def available_ollama_models():
|
|
44
|
+
"""Return the locally-pulled Ollama model ids, e.g. ``["qwen2.5:7b", ...]``.
|
|
45
|
+
|
|
46
|
+
Uses the Ollama Python client. Returns an empty list if Ollama is not
|
|
47
|
+
running or has no models pulled, so callers can fall back to the default.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
response = ollama.list()
|
|
51
|
+
except Exception: # noqa: BLE001 - Ollama may be down; caller falls back to the default
|
|
52
|
+
return []
|
|
53
|
+
# ollama-python >= 0.4 returns an object with a ``.models`` list of items
|
|
54
|
+
# exposing ``.model``; older versions return a dict of {"name"/"model": ...}.
|
|
55
|
+
models = getattr(response, "models", None)
|
|
56
|
+
if models is None and isinstance(response, dict):
|
|
57
|
+
models = response.get("models", [])
|
|
58
|
+
names = []
|
|
59
|
+
for model in models or []:
|
|
60
|
+
name = getattr(model, "model", None)
|
|
61
|
+
if name is None and isinstance(model, dict):
|
|
62
|
+
name = model.get("model") or model.get("name")
|
|
63
|
+
if name:
|
|
64
|
+
names.append(name)
|
|
65
|
+
return names
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_ollama_running():
|
|
69
|
+
"""Whether the Ollama server answers at DEFAULT_OLLAMA_HOST."""
|
|
70
|
+
try:
|
|
71
|
+
ollama.list()
|
|
72
|
+
except Exception: # noqa: BLE001 - any failure to reach Ollama means "not running"
|
|
73
|
+
return False
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ollama_readiness_problem(model_id):
|
|
78
|
+
"""Explain what is wrong with the Ollama setup, or return None if it is ready.
|
|
79
|
+
|
|
80
|
+
Without this the failure surfaces as a raw ConnectionError from deep inside
|
|
81
|
+
the Strands/Ollama stack, which tells the user nothing about what to fix.
|
|
82
|
+
Each of the three ways this goes wrong needs a different fix, so they get
|
|
83
|
+
different messages.
|
|
84
|
+
"""
|
|
85
|
+
if not is_ollama_running():
|
|
86
|
+
return (
|
|
87
|
+
f"Cannot reach Ollama at {DEFAULT_OLLAMA_HOST}.\n"
|
|
88
|
+
" - Start it: open the Ollama app, or run `ollama serve` in another terminal.\n"
|
|
89
|
+
" - Not installed? https://ollama.com/download\n"
|
|
90
|
+
" - Listening somewhere else? Set OLLAMA_HOST in your .env.local1."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
models = available_ollama_models()
|
|
94
|
+
if not models:
|
|
95
|
+
return (
|
|
96
|
+
"Ollama is running but you have no models pulled.\n"
|
|
97
|
+
f" Pull one, e.g.: ollama pull {model_id}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if model_id not in models:
|
|
101
|
+
return (
|
|
102
|
+
f"Ollama is running but the model '{model_id}' is not pulled.\n"
|
|
103
|
+
f" Pull it: ollama pull {model_id}\n"
|
|
104
|
+
f" Or pick one of: {', '.join(models)}\n"
|
|
105
|
+
" (the default comes from OLLAMA_MODEL_ID in your .env.local1)"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def choose_model(default_model_id):
|
|
112
|
+
"""Let the user pick from the locally-installed Ollama models at startup.
|
|
113
|
+
|
|
114
|
+
Falls back to ``default_model_id`` when input is non-interactive (tests /
|
|
115
|
+
CI / piped stdin), when Ollama lists no models, or when the user just
|
|
116
|
+
presses Enter.
|
|
117
|
+
"""
|
|
118
|
+
models = available_ollama_models()
|
|
119
|
+
if not models or not sys.stdin.isatty():
|
|
120
|
+
return default_model_id
|
|
121
|
+
|
|
122
|
+
default_installed = default_model_id in models
|
|
123
|
+
fallback = default_model_id if default_installed else models[0]
|
|
124
|
+
if not default_installed:
|
|
125
|
+
# Don't silently redirect: tell the user their configured default
|
|
126
|
+
# isn't pulled, so the "(default)" marker below (which only marks the
|
|
127
|
+
# real configured default) isn't misleading.
|
|
128
|
+
print(
|
|
129
|
+
f"Configured default {default_model_id!r} is not installed; "
|
|
130
|
+
f"falling back to {fallback!r}."
|
|
131
|
+
)
|
|
132
|
+
print("Available Ollama models:")
|
|
133
|
+
for index, name in enumerate(models, start=1):
|
|
134
|
+
marker = " (default)" if name == default_model_id else ""
|
|
135
|
+
print(f" {index}. {name}{marker}")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
choice = input(f"Pick a model by number [default: {fallback}]: ").strip()
|
|
139
|
+
except (KeyboardInterrupt, EOFError):
|
|
140
|
+
return fallback
|
|
141
|
+
if not choice:
|
|
142
|
+
return fallback
|
|
143
|
+
if choice.isdigit() and 1 <= int(choice) <= len(models):
|
|
144
|
+
return models[int(choice) - 1]
|
|
145
|
+
print(f"'{choice}' is not a valid choice; using {fallback}.")
|
|
146
|
+
return fallback
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_chat_loop(build_agent, title, examples, default_model_id=DEFAULT_OLLAMA_MODEL_ID):
|
|
150
|
+
"""Run an agent as an interactive terminal chat, so we can test it by hand.
|
|
151
|
+
|
|
152
|
+
Checks Ollama is usable before starting, so a missing server or an unpulled
|
|
153
|
+
model is reported as something the user can act on rather than as a stack
|
|
154
|
+
trace on their first question.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
build_agent: Callable taking a model id and returning the Agent to chat
|
|
158
|
+
with. Called after the user has picked the model at startup.
|
|
159
|
+
title: Shown at the top, e.g. "Ollama Contact Agent".
|
|
160
|
+
examples: Example questions printed as a hint.
|
|
161
|
+
default_model_id: The pre-selected model in the startup picker.
|
|
162
|
+
"""
|
|
163
|
+
if not is_ollama_running():
|
|
164
|
+
print(f"\n{ollama_readiness_problem(default_model_id)}\n")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Picked first: the user may choose a model other than the (possibly
|
|
168
|
+
# unpulled) default, and it is the chosen one that has to be usable.
|
|
169
|
+
selected_model_id = choose_model(default_model_id)
|
|
170
|
+
|
|
171
|
+
problem = ollama_readiness_problem(selected_model_id)
|
|
172
|
+
if problem:
|
|
173
|
+
print(f"\n{problem}\n")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
agent = build_agent(selected_model_id)
|
|
177
|
+
|
|
178
|
+
print(f"\n{title} (local {selected_model_id})\n")
|
|
179
|
+
print("Ask, for example:")
|
|
180
|
+
for example in examples:
|
|
181
|
+
print(f" {example}")
|
|
182
|
+
print("Type 'exit' to quit.\n")
|
|
183
|
+
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
user_input = input("> ").strip()
|
|
187
|
+
if not user_input:
|
|
188
|
+
continue
|
|
189
|
+
if user_input.lower() == "exit":
|
|
190
|
+
print("\nGoodbye!")
|
|
191
|
+
break
|
|
192
|
+
# The agent streams its answer to stdout itself, so we don't reprint it.
|
|
193
|
+
agent(user_input)
|
|
194
|
+
print()
|
|
195
|
+
except (KeyboardInterrupt, EOFError):
|
|
196
|
+
print("\nExiting...")
|
|
197
|
+
break
|
|
198
|
+
except Exception as error: # noqa: BLE001 - surface any error to the user
|
|
199
|
+
# Ollama can also go away mid-chat (laptop slept, app quit). Say so,
|
|
200
|
+
# instead of blaming the question the user just asked.
|
|
201
|
+
if not is_ollama_running():
|
|
202
|
+
print(f"\n{ollama_readiness_problem(selected_model_id)}\n")
|
|
203
|
+
break
|
|
204
|
+
print(f"\nError: {error}\nPlease try a different request.")
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
The Ollama plumbing (model picker, agent construction, chat loop) is shared with
|
|
15
|
+
the other agents in this package -- see ollama_agent_base.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
from strands import tool
|
|
20
|
+
|
|
21
|
+
from ollama_agent_base import DEFAULT_OLLAMA_MODEL_ID, build_ollama_agent, run_chat_loop
|
|
22
|
+
|
|
23
|
+
# Free US National Weather Service API. It requires a descriptive User-Agent;
|
|
24
|
+
# requests without one can be rejected.
|
|
25
|
+
NWS_API_BASE = "https://api.weather.gov"
|
|
26
|
+
NWS_HEADERS = {
|
|
27
|
+
"User-Agent": "circlez-ai-local-weather-agent (contact: dev@circ.zone)",
|
|
28
|
+
"Accept": "application/geo+json",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@tool
|
|
33
|
+
def get_forecast(latitude: float, longitude: float) -> str:
|
|
34
|
+
"""Get the current weather forecast for a US location.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
latitude: Latitude in decimal degrees (e.g. 47.6062 for Seattle).
|
|
38
|
+
longitude: Longitude in decimal degrees (e.g. -122.3321 for Seattle).
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A short human-readable forecast for the upcoming period, or a message
|
|
42
|
+
explaining that no forecast is available (e.g. the coordinates are
|
|
43
|
+
outside the US, which the NWS does not cover).
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
points = requests.get(
|
|
47
|
+
f"{NWS_API_BASE}/points/{latitude},{longitude}",
|
|
48
|
+
headers=NWS_HEADERS,
|
|
49
|
+
timeout=10,
|
|
50
|
+
)
|
|
51
|
+
if points.status_code != 200:
|
|
52
|
+
return (
|
|
53
|
+
f"No weather data for {latitude},{longitude} -- the National "
|
|
54
|
+
"Weather Service only covers locations in the United States."
|
|
55
|
+
)
|
|
56
|
+
forecast_url = points.json()["properties"]["forecast"]
|
|
57
|
+
|
|
58
|
+
forecast = requests.get(forecast_url, headers=NWS_HEADERS, timeout=10)
|
|
59
|
+
forecast.raise_for_status()
|
|
60
|
+
period = forecast.json()["properties"]["periods"][0]
|
|
61
|
+
except (requests.RequestException, KeyError, IndexError) as error:
|
|
62
|
+
return f"Could not retrieve the forecast: {error}"
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
f"{period['name']}: {period['temperature']}°{period['temperatureUnit']}, "
|
|
66
|
+
f"{period['shortForecast']} "
|
|
67
|
+
f"(wind {period['windSpeed']} {period['windDirection']})."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
WEATHER_SYSTEM_PROMPT = """You are a weather assistant for locations in the United States.
|
|
72
|
+
|
|
73
|
+
You have one tool: get_forecast(latitude, longitude). To answer a weather question:
|
|
74
|
+
1. Work out the latitude and longitude of the location from your own knowledge.
|
|
75
|
+
2. Call get_forecast with those coordinates.
|
|
76
|
+
3. Reply in ONE or TWO short sentences that state the temperature and the conditions.
|
|
77
|
+
|
|
78
|
+
Rules -- follow them strictly:
|
|
79
|
+
- Do NOT narrate your steps or say what you are "about to" do. Call the tool, then
|
|
80
|
+
give only the final answer.
|
|
81
|
+
- If get_forecast says there is no data or that it could not retrieve the forecast,
|
|
82
|
+
tell the user you could not get weather for that location. Never invent a forecast.
|
|
83
|
+
- If the location is not a real US place, say you cannot find it instead of guessing.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_agent(model_id=DEFAULT_OLLAMA_MODEL_ID):
|
|
88
|
+
"""Build the weather Agent backed by a local Ollama model.
|
|
89
|
+
|
|
90
|
+
Kept as a factory so the interactive picker can build the agent with the
|
|
91
|
+
model the user chose at startup, while imports/tests still get a ready-made
|
|
92
|
+
agent from the configured default (see ``weather_agent`` below).
|
|
93
|
+
"""
|
|
94
|
+
return build_ollama_agent(
|
|
95
|
+
model_id=model_id,
|
|
96
|
+
system_prompt=WEATHER_SYSTEM_PROMPT,
|
|
97
|
+
tools=[get_forecast],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# A ready-made agent on the configured default, so importing the module (and the
|
|
102
|
+
# tests) get a working agent without any interactive prompt.
|
|
103
|
+
weather_agent = build_agent()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Interactive loop so we can test it from the terminal.
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
run_chat_loop(
|
|
109
|
+
build_agent=build_agent,
|
|
110
|
+
title="Ollama Weather Agent",
|
|
111
|
+
examples=["What's the weather like in Seattle?"],
|
|
112
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Placeholder test for the artificial-intelligence-local package.
|
|
2
|
+
|
|
3
|
+
The template example tests were removed in the BU-3046 cleanup. Real tests
|
|
4
|
+
(e.g. the weather agent and the contacts agent) are added in later PRs.
|
|
5
|
+
This keeps the test suite green so the build/GHA has at least one test to collect.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_placeholder():
|
|
10
|
+
assert True
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Tests for the Ollama contacts agent.
|
|
2
|
+
|
|
3
|
+
Every tool reaches the database through the contact-local "_strand" twins, and
|
|
4
|
+
importing those logs in against the local authentication server. So the tests
|
|
5
|
+
mock at that boundary -- `contact_agent._strand_functions` -- which keeps them
|
|
6
|
+
fast and deterministic and means they need no database, no auth server and no
|
|
7
|
+
LLM. The live test at the bottom is skipped by default; it exercises the full
|
|
8
|
+
Ollama -> tool -> answer path and needs the infrastructure actually running.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
from unittest.mock import MagicMock, patch
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
import contact_agent
|
|
17
|
+
|
|
18
|
+
# A contact as the contact view returns it: phone1..3 / email1..3 columns.
|
|
19
|
+
TAL_CONTACT_DICT = {
|
|
20
|
+
"contact_id": 1,
|
|
21
|
+
"first_name": "Tal",
|
|
22
|
+
"last_name": "Cohen",
|
|
23
|
+
"job_title": "Developer",
|
|
24
|
+
"organization": "Circlez",
|
|
25
|
+
"phone1": "+972501234567",
|
|
26
|
+
"phone2": "",
|
|
27
|
+
"phone3": None,
|
|
28
|
+
"email1": "tal@circ.zone",
|
|
29
|
+
"email2": None,
|
|
30
|
+
"email3": None,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fake_strands(**overrides):
|
|
35
|
+
"""A stand-in for contact_local.contact_local_strands.
|
|
36
|
+
|
|
37
|
+
Defaults behave like the real twins over TAL_CONTACT_DICT; pass an override
|
|
38
|
+
to make one of them return something else or raise.
|
|
39
|
+
"""
|
|
40
|
+
strand_functions = SimpleNamespace(
|
|
41
|
+
get_contact_by_contact_id_strand=MagicMock(return_value=TAL_CONTACT_DICT),
|
|
42
|
+
get_existing_contact_dict_strand=MagicMock(return_value=TAL_CONTACT_DICT),
|
|
43
|
+
get_contact_phone_numbers_from_contact_dict_strand=MagicMock(
|
|
44
|
+
return_value=["+972501234567"]
|
|
45
|
+
),
|
|
46
|
+
get_contact_email_addresses_from_contact_dict_strand=MagicMock(
|
|
47
|
+
return_value=["tal@circ.zone"]
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
for name, value in overrides.items():
|
|
51
|
+
setattr(strand_functions, name, value)
|
|
52
|
+
return strand_functions
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _patch_strands(**overrides):
|
|
56
|
+
return patch.object(contact_agent, "_strand_functions", return_value=_fake_strands(**overrides))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _registered_tools():
|
|
60
|
+
"""Every tool the agent is actually built with.
|
|
61
|
+
|
|
62
|
+
Read from the registry rather than listed by hand, so a tool added later is
|
|
63
|
+
covered by the checks below without anyone remembering to add it here.
|
|
64
|
+
"""
|
|
65
|
+
return list(contact_agent.contact_agent.tool_registry.registry.values())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_contact_agent_is_built():
|
|
69
|
+
"""The module exposes a constructed agent, its tools, and a system prompt."""
|
|
70
|
+
assert contact_agent.contact_agent is not None
|
|
71
|
+
assert isinstance(contact_agent.CONTACT_SYSTEM_PROMPT, str)
|
|
72
|
+
assert contact_agent.CONTACT_SYSTEM_PROMPT.strip() != ""
|
|
73
|
+
|
|
74
|
+
# A subset check, not equality. A tool going *missing* from build_agent() is a real
|
|
75
|
+
# bug that nothing else catches -- every tool function is also tested directly, so
|
|
76
|
+
# they stay green while the agent quietly loses the ability to call one. A tool being
|
|
77
|
+
# *added* is a deliberate act, and failing on that would be friction with no payoff.
|
|
78
|
+
tool_names = {tool.tool_name for tool in _registered_tools()}
|
|
79
|
+
assert {
|
|
80
|
+
"get_contact_by_id",
|
|
81
|
+
"get_contact_phone_numbers",
|
|
82
|
+
"get_contact_email_addresses",
|
|
83
|
+
"find_contact_by_phone_or_email",
|
|
84
|
+
} <= tool_names
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_importing_the_module_does_not_touch_the_database():
|
|
88
|
+
"""contact_local is imported lazily, inside the tools.
|
|
89
|
+
|
|
90
|
+
Importing it logs in against the auth server, so if it happened at module
|
|
91
|
+
import the agent could not even be imported without the infrastructure --
|
|
92
|
+
and neither could these tests.
|
|
93
|
+
"""
|
|
94
|
+
assert "contact_local" not in contact_agent.__dict__
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_tools_take_only_flat_scalar_arguments():
|
|
98
|
+
"""No tool may take a dict -- checked across every tool the agent is built with.
|
|
99
|
+
|
|
100
|
+
The strand twins speak `contact_dict`, but a small local model cannot be
|
|
101
|
+
trusted to synthesize one -- it invents keys. Tools compose the dict-shaped
|
|
102
|
+
calls internally instead, so every exposed argument stays a scalar.
|
|
103
|
+
|
|
104
|
+
This reads the registry rather than a hand-written list on purpose. A new tool
|
|
105
|
+
wrapping a new ContactsLocal method is exactly where the dict would creep back
|
|
106
|
+
in, and a hardcoded list would go on passing while silently covering none of it.
|
|
107
|
+
"""
|
|
108
|
+
scalar_types = {"string", "integer", "number", "boolean"}
|
|
109
|
+
tools = _registered_tools()
|
|
110
|
+
assert tools, "no tools registered -- this test would otherwise pass vacuously"
|
|
111
|
+
|
|
112
|
+
for tool in tools:
|
|
113
|
+
properties = tool.tool_spec["inputSchema"]["json"]["properties"]
|
|
114
|
+
for argument_name, schema in properties.items():
|
|
115
|
+
assert schema.get("type") in scalar_types, (
|
|
116
|
+
f"{tool.tool_name}({argument_name}) is {schema.get('type')}, not a scalar"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_get_contact_by_id_describes_the_contact():
|
|
121
|
+
with _patch_strands():
|
|
122
|
+
answer = contact_agent.get_contact_by_id(1)
|
|
123
|
+
|
|
124
|
+
assert "Tal Cohen" in answer
|
|
125
|
+
assert "Circlez" in answer
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_get_contact_by_id_unknown_contact_is_reported():
|
|
129
|
+
"""Negative case: an id nobody has must be reported, not invented."""
|
|
130
|
+
with _patch_strands(get_contact_by_contact_id_strand=MagicMock(return_value={})):
|
|
131
|
+
answer = contact_agent.get_contact_by_id(999)
|
|
132
|
+
|
|
133
|
+
assert "no contact with id 999" in answer
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_get_contact_phone_numbers_composes_the_two_strand_calls():
|
|
137
|
+
"""The tool fetches the contact, then reads the phones off it."""
|
|
138
|
+
strand_functions = _fake_strands()
|
|
139
|
+
with patch.object(contact_agent, "_strand_functions", return_value=strand_functions):
|
|
140
|
+
answer = contact_agent.get_contact_phone_numbers(1)
|
|
141
|
+
|
|
142
|
+
assert "+972501234567" in answer
|
|
143
|
+
strand_functions.get_contact_by_contact_id_strand.assert_called_once_with(1)
|
|
144
|
+
strand_functions.get_contact_phone_numbers_from_contact_dict_strand.assert_called_once_with(
|
|
145
|
+
TAL_CONTACT_DICT
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_get_contact_phone_numbers_when_there_are_none():
|
|
150
|
+
with _patch_strands(
|
|
151
|
+
get_contact_phone_numbers_from_contact_dict_strand=MagicMock(return_value=[])
|
|
152
|
+
):
|
|
153
|
+
answer = contact_agent.get_contact_phone_numbers(1)
|
|
154
|
+
|
|
155
|
+
assert "no phone number" in answer
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_get_contact_phone_numbers_unknown_contact_never_reads_fields():
|
|
159
|
+
"""If no contact has that id, don't go on to read phones off an empty dict."""
|
|
160
|
+
strand_functions = _fake_strands(get_contact_by_contact_id_strand=MagicMock(return_value={}))
|
|
161
|
+
with patch.object(contact_agent, "_strand_functions", return_value=strand_functions):
|
|
162
|
+
answer = contact_agent.get_contact_phone_numbers(999)
|
|
163
|
+
|
|
164
|
+
assert "no contact with id 999" in answer
|
|
165
|
+
strand_functions.get_contact_phone_numbers_from_contact_dict_strand.assert_not_called()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_get_contact_email_addresses_returns_them():
|
|
169
|
+
with _patch_strands():
|
|
170
|
+
answer = contact_agent.get_contact_email_addresses(1)
|
|
171
|
+
|
|
172
|
+
assert "tal@circ.zone" in answer
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_find_contact_by_email_reports_the_contact_id():
|
|
176
|
+
"""The id is what lets the model chain into the other tools, so it must be there."""
|
|
177
|
+
strand_functions = _fake_strands()
|
|
178
|
+
with patch.object(contact_agent, "_strand_functions", return_value=strand_functions):
|
|
179
|
+
answer = contact_agent.find_contact_by_phone_or_email(email_address="tal@circ.zone")
|
|
180
|
+
|
|
181
|
+
assert "contact 1" in answer
|
|
182
|
+
assert "Tal Cohen" in answer
|
|
183
|
+
# The model passed a flat string; the tool built the contact_dict itself.
|
|
184
|
+
strand_functions.get_existing_contact_dict_strand.assert_called_once_with(
|
|
185
|
+
{"email1": "tal@circ.zone"}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_find_contact_by_phone_builds_the_phone_column():
|
|
190
|
+
strand_functions = _fake_strands()
|
|
191
|
+
with patch.object(contact_agent, "_strand_functions", return_value=strand_functions):
|
|
192
|
+
contact_agent.find_contact_by_phone_or_email(phone_number="+972501234567")
|
|
193
|
+
|
|
194
|
+
strand_functions.get_existing_contact_dict_strand.assert_called_once_with(
|
|
195
|
+
{"phone1": "+972501234567"}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_find_contact_no_match_is_reported():
|
|
200
|
+
with _patch_strands(get_existing_contact_dict_strand=MagicMock(return_value={})):
|
|
201
|
+
answer = contact_agent.find_contact_by_phone_or_email(email_address="nobody@circ.zone")
|
|
202
|
+
|
|
203
|
+
assert "no contact" in answer.lower()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_find_contact_with_no_arguments_asks_for_one():
|
|
207
|
+
"""Guard against the model calling the search tool with nothing to search on."""
|
|
208
|
+
with _patch_strands():
|
|
209
|
+
answer = contact_agent.find_contact_by_phone_or_email()
|
|
210
|
+
|
|
211
|
+
assert "phone number or an email address" in answer
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_database_failure_is_returned_as_a_message_not_raised():
|
|
215
|
+
"""A DB/auth failure must come back as text the model can relay.
|
|
216
|
+
|
|
217
|
+
If it raised, the exception would surface as an agent crash mid-chat instead
|
|
218
|
+
of the agent telling the user it could not look the contact up.
|
|
219
|
+
"""
|
|
220
|
+
with _patch_strands(
|
|
221
|
+
get_contact_by_contact_id_strand=MagicMock(side_effect=ConnectionError("auth server down"))
|
|
222
|
+
):
|
|
223
|
+
answer = contact_agent.get_contact_by_id(1)
|
|
224
|
+
|
|
225
|
+
assert "LOOKUP FAILED" in answer
|
|
226
|
+
assert "could not be reached" in answer
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_database_failure_is_never_phrased_as_a_missing_contact():
|
|
230
|
+
"""Regression: a backend outage must not read as "that contact doesn't exist".
|
|
231
|
+
|
|
232
|
+
We used to hand the model the raw exception. When the auth server 500s, that
|
|
233
|
+
is a wall of JSON, and qwen2.5:7b compressed it into "There is no contact
|
|
234
|
+
with the id 1" -- reporting a DB outage as a confident, wrong answer about a
|
|
235
|
+
person. The failure text must not look like a not-found, and must not carry
|
|
236
|
+
the raw error for the model to (mis)interpret.
|
|
237
|
+
"""
|
|
238
|
+
auth_500 = Exception('{"message":"Server error ... Cannot read properties of undefined"}')
|
|
239
|
+
|
|
240
|
+
failures = []
|
|
241
|
+
for tool, arguments in (
|
|
242
|
+
(contact_agent.get_contact_by_id, (1,)),
|
|
243
|
+
(contact_agent.get_contact_phone_numbers, (1,)),
|
|
244
|
+
(contact_agent.get_contact_email_addresses, (1,)),
|
|
245
|
+
):
|
|
246
|
+
with _patch_strands(get_contact_by_contact_id_strand=MagicMock(side_effect=auth_500)):
|
|
247
|
+
failures.append(tool(*arguments))
|
|
248
|
+
with _patch_strands(get_existing_contact_dict_strand=MagicMock(side_effect=auth_500)):
|
|
249
|
+
failures.append(contact_agent.find_contact_by_phone_or_email(email_address="a@b.com"))
|
|
250
|
+
|
|
251
|
+
for answer in failures:
|
|
252
|
+
assert "LOOKUP FAILED" in answer
|
|
253
|
+
# Must not read as "no such contact"...
|
|
254
|
+
assert "no contact" not in answer.lower()
|
|
255
|
+
assert "does NOT mean the contact does not exist" in answer
|
|
256
|
+
# ...and must not leak the raw error the model would try to summarize.
|
|
257
|
+
assert "Cannot read properties" not in answer
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@pytest.mark.skip(
|
|
261
|
+
reason="live test: needs Ollama running, the database, and the auth server on :3000"
|
|
262
|
+
)
|
|
263
|
+
def test_contact_agent_answers_live():
|
|
264
|
+
"""End-to-end: ask for a contact and expect the real phone number back.
|
|
265
|
+
|
|
266
|
+
The LLM is non-deterministic, so assert the answer carries the fact from the
|
|
267
|
+
database rather than checking exact wording. Set CONTACT_ID / EXPECTED_PHONE
|
|
268
|
+
to a contact that exists in your local database before un-skipping.
|
|
269
|
+
"""
|
|
270
|
+
contact_id = 1
|
|
271
|
+
expected_phone = "+972501234567"
|
|
272
|
+
|
|
273
|
+
answer = str(contact_agent.contact_agent(f"What's the phone number of contact {contact_id}?"))
|
|
274
|
+
|
|
275
|
+
assert expected_phone in answer
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for the Ollama plumbing shared by the agents in this package.
|
|
2
|
+
|
|
3
|
+
These cover the model picker and the model listing -- the parts every agent
|
|
4
|
+
reuses. They mock the Ollama client, so no Ollama server is needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import ollama_agent_base
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_available_ollama_models_parses_client_response():
|
|
13
|
+
"""Model ids are extracted from the ollama client's `.models[].model`."""
|
|
14
|
+
item = MagicMock()
|
|
15
|
+
item.model = "qwen2.5:7b"
|
|
16
|
+
response = MagicMock(models=[item])
|
|
17
|
+
|
|
18
|
+
with patch("ollama_agent_base.ollama.list", return_value=response):
|
|
19
|
+
assert ollama_agent_base.available_ollama_models() == ["qwen2.5:7b"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_available_ollama_models_empty_when_ollama_down():
|
|
23
|
+
"""If the ollama client raises (server down), we return [] to fall back."""
|
|
24
|
+
with patch("ollama_agent_base.ollama.list", side_effect=ConnectionError):
|
|
25
|
+
assert ollama_agent_base.available_ollama_models() == []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_choose_model_non_interactive_uses_default_without_prompting():
|
|
29
|
+
"""In tests / CI (stdin not a tty) the configured default is used, no input()."""
|
|
30
|
+
with patch("ollama_agent_base.available_ollama_models", return_value=["a", "b"]), \
|
|
31
|
+
patch("ollama_agent_base.sys.stdin.isatty", return_value=False), \
|
|
32
|
+
patch("ollama_agent_base.input", side_effect=AssertionError("must not prompt")):
|
|
33
|
+
assert ollama_agent_base.choose_model("b") == "b"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_choose_model_picks_by_number():
|
|
37
|
+
"""A valid number selects the matching model from the listed order."""
|
|
38
|
+
with patch("ollama_agent_base.available_ollama_models", return_value=["a", "b", "c"]), \
|
|
39
|
+
patch("ollama_agent_base.sys.stdin.isatty", return_value=True), \
|
|
40
|
+
patch("ollama_agent_base.input", return_value="3"):
|
|
41
|
+
assert ollama_agent_base.choose_model("a") == "c"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_choose_model_invalid_choice_falls_back_to_default():
|
|
45
|
+
"""Empty / out-of-range / garbage input falls back to the default model."""
|
|
46
|
+
with patch("ollama_agent_base.available_ollama_models", return_value=["a", "b"]), \
|
|
47
|
+
patch("ollama_agent_base.sys.stdin.isatty", return_value=True), \
|
|
48
|
+
patch("ollama_agent_base.input", return_value="99"):
|
|
49
|
+
assert ollama_agent_base.choose_model("a") == "a"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_choose_model_default_not_installed_notes_and_uses_first(capsys):
|
|
53
|
+
"""If the configured default isn't installed, note it and fall back to the
|
|
54
|
+
first listed model (pressing Enter selects that fallback)."""
|
|
55
|
+
with patch("ollama_agent_base.available_ollama_models", return_value=["a", "b"]), \
|
|
56
|
+
patch("ollama_agent_base.sys.stdin.isatty", return_value=True), \
|
|
57
|
+
patch("ollama_agent_base.input", return_value=""):
|
|
58
|
+
assert ollama_agent_base.choose_model("not-installed") == "a"
|
|
59
|
+
|
|
60
|
+
out = capsys.readouterr().out
|
|
61
|
+
assert "not-installed" in out and "not installed" in out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_ollama_down_is_reported_with_how_to_start_it():
|
|
65
|
+
"""The commonest failure: Ollama isn't running. Say so, and how to fix it."""
|
|
66
|
+
with patch("ollama_agent_base.ollama.list", side_effect=ConnectionError):
|
|
67
|
+
problem = ollama_agent_base.ollama_readiness_problem("qwen2.5:7b")
|
|
68
|
+
|
|
69
|
+
assert "Cannot reach Ollama" in problem
|
|
70
|
+
assert "ollama serve" in problem
|
|
71
|
+
assert "OLLAMA_HOST" in problem
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_no_models_pulled_is_reported_with_the_pull_command():
|
|
75
|
+
with patch("ollama_agent_base.is_ollama_running", return_value=True), \
|
|
76
|
+
patch("ollama_agent_base.available_ollama_models", return_value=[]):
|
|
77
|
+
problem = ollama_agent_base.ollama_readiness_problem("qwen2.5:7b")
|
|
78
|
+
|
|
79
|
+
assert "no models pulled" in problem
|
|
80
|
+
assert "ollama pull qwen2.5:7b" in problem
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_unpulled_model_is_reported_and_lists_what_you_do_have():
|
|
84
|
+
"""Configured a model you never pulled: name it, and show the alternatives."""
|
|
85
|
+
with patch("ollama_agent_base.is_ollama_running", return_value=True), \
|
|
86
|
+
patch("ollama_agent_base.available_ollama_models", return_value=["llama3.2:3b"]):
|
|
87
|
+
problem = ollama_agent_base.ollama_readiness_problem("qwen2.5:7b")
|
|
88
|
+
|
|
89
|
+
assert "'qwen2.5:7b' is not pulled" in problem
|
|
90
|
+
assert "ollama pull qwen2.5:7b" in problem
|
|
91
|
+
assert "llama3.2:3b" in problem
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_no_problem_when_ollama_is_ready():
|
|
95
|
+
with patch("ollama_agent_base.is_ollama_running", return_value=True), \
|
|
96
|
+
patch("ollama_agent_base.available_ollama_models", return_value=["qwen2.5:7b"]):
|
|
97
|
+
assert ollama_agent_base.ollama_readiness_problem("qwen2.5:7b") is None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_chat_loop_refuses_to_start_when_ollama_is_down(capsys):
|
|
101
|
+
"""It must not build an agent or prompt for input if Ollama is unreachable."""
|
|
102
|
+
build_agent = MagicMock(side_effect=AssertionError("must not build an agent"))
|
|
103
|
+
|
|
104
|
+
with patch("ollama_agent_base.is_ollama_running", return_value=False), \
|
|
105
|
+
patch("ollama_agent_base.input", side_effect=AssertionError("must not prompt")):
|
|
106
|
+
ollama_agent_base.run_chat_loop(build_agent, title="Test Agent", examples=[])
|
|
107
|
+
|
|
108
|
+
assert "Cannot reach Ollama" in capsys.readouterr().out
|
|
109
|
+
build_agent.assert_not_called()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_build_ollama_agent_wires_prompt_and_tools():
|
|
113
|
+
"""The agent is built on the given system prompt and tool set."""
|
|
114
|
+
tool_function = MagicMock(tool_name="a_tool")
|
|
115
|
+
|
|
116
|
+
with patch("ollama_agent_base.OllamaModel") as ollama_model, \
|
|
117
|
+
patch("ollama_agent_base.Agent") as agent:
|
|
118
|
+
ollama_agent_base.build_ollama_agent(
|
|
119
|
+
model_id="qwen2.5:7b", system_prompt="be helpful", tools=[tool_function]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
assert ollama_model.call_args.kwargs["model_id"] == "qwen2.5:7b"
|
|
123
|
+
assert agent.call_args.kwargs["system_prompt"] == "be helpful"
|
|
124
|
+
assert agent.call_args.kwargs["tools"] == [tool_function]
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
The model picker and the agent construction now live in ollama_agent_base and
|
|
9
|
+
are tested in ollama_agent_base_test.py.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from unittest.mock import MagicMock, patch
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
import weather_agent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_weather_agent_is_built():
|
|
20
|
+
"""The module exposes a constructed agent, its tool, and a system prompt."""
|
|
21
|
+
assert weather_agent.weather_agent is not None
|
|
22
|
+
assert weather_agent.get_forecast is not None
|
|
23
|
+
assert isinstance(weather_agent.WEATHER_SYSTEM_PROMPT, str)
|
|
24
|
+
assert weather_agent.WEATHER_SYSTEM_PROMPT.strip() != ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _mock_response(status_code, payload):
|
|
28
|
+
response = MagicMock(status_code=status_code)
|
|
29
|
+
response.json.return_value = payload
|
|
30
|
+
return response
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_get_forecast_formats_period():
|
|
34
|
+
"""A valid location returns a one-line forecast with temperature + conditions."""
|
|
35
|
+
points = _mock_response(200, {
|
|
36
|
+
"properties": {"forecast": "https://api.weather.gov/gridpoints/SEW/124,67/forecast"},
|
|
37
|
+
})
|
|
38
|
+
forecast = _mock_response(200, {
|
|
39
|
+
"properties": {"periods": [{
|
|
40
|
+
"name": "Tonight",
|
|
41
|
+
"temperature": 53,
|
|
42
|
+
"temperatureUnit": "F",
|
|
43
|
+
"shortForecast": "Mostly Cloudy",
|
|
44
|
+
"windSpeed": "8 mph",
|
|
45
|
+
"windDirection": "SSW",
|
|
46
|
+
}]},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
# get_forecast makes two GETs: points, then the forecast URL.
|
|
50
|
+
with patch("weather_agent.requests.get", side_effect=[points, forecast]):
|
|
51
|
+
answer = weather_agent.get_forecast(47.6062, -122.3321)
|
|
52
|
+
|
|
53
|
+
assert "53°F" in answer
|
|
54
|
+
assert "Mostly Cloudy" in answer
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_get_forecast_outside_us_is_refused():
|
|
58
|
+
"""Negative case: a non-US / unknown location is refused, not invented.
|
|
59
|
+
|
|
60
|
+
The NWS only covers the US and returns a non-200 for anything else, so the
|
|
61
|
+
tool must say it has no data — and must NOT fabricate a temperature.
|
|
62
|
+
"""
|
|
63
|
+
with patch("weather_agent.requests.get", return_value=_mock_response(404, {})):
|
|
64
|
+
answer = weather_agent.get_forecast(0, 0)
|
|
65
|
+
|
|
66
|
+
assert "United States" in answer
|
|
67
|
+
assert "°F" not in answer
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.skip(reason="live test: needs Ollama running + internet to api.weather.gov")
|
|
71
|
+
def test_weather_agent_answers_live():
|
|
72
|
+
"""End-to-end: ask for a US city and expect a weather-shaped answer.
|
|
73
|
+
|
|
74
|
+
The LLM is non-deterministic, so we assert the answer is non-empty AND
|
|
75
|
+
mentions a weather concept rather than checking exact wording.
|
|
76
|
+
"""
|
|
77
|
+
weather_terms = ("°f", "temperature", "forecast", "cloudy", "rain", "sunny", "wind")
|
|
78
|
+
answer = str(weather_agent.weather_agent("What's the weather like in Seattle?")).strip().lower()
|
|
79
|
+
|
|
80
|
+
assert answer != ""
|
|
81
|
+
assert any(term in answer for term in weather_terms), (
|
|
82
|
+
f"answer did not look weather-related: {answer!r}"
|
|
83
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: artificial-intelligence-local
|
|
3
|
+
Version: 0.0.2b3115
|
|
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>=0.0.189
|
|
13
|
+
Requires-Dist: strands-agents
|
|
14
|
+
Requires-Dist: ollama
|
|
15
|
+
Requires-Dist: python-dotenv
|
|
16
|
+
Requires-Dist: requests
|
|
17
|
+
Requires-Dist: contact-local>=0.0.72b3047
|
|
18
|
+
Dynamic: author
|
|
19
|
+
Dynamic: author-email
|
|
20
|
+
Dynamic: classifier
|
|
21
|
+
Dynamic: description
|
|
22
|
+
Dynamic: description-content-type
|
|
23
|
+
Dynamic: home-page
|
|
24
|
+
Dynamic: requires-dist
|
|
25
|
+
Dynamic: summary
|
|
26
|
+
|
|
27
|
+
PyPI artificial-intelligence-local Python Package owned by Circlez.ai
|
|
28
|
+
JIRA Work Item: https://circles-zone.atlassian.net/browse/BU-3046
|
|
29
|
+
GHA: https://github.com/circles-zone/artificial-intelligence-local-python-package/actions
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
artificial_intelligence_local/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
artificial_intelligence_local/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
artificial_intelligence_local/src/contact_agent.py,sha256=Ahb7Gm4qZHLEGy8h8oQ3YQrmZGD-ISgJzUsi_CieFH4,11641
|
|
4
|
+
artificial_intelligence_local/src/ollama_agent_base.py,sha256=6CvMhQODg_evwIukOrM5gHK9IdzWAAy4o776F__MEZk,8134
|
|
5
|
+
artificial_intelligence_local/src/weather_agent.py,sha256=Obv3pPSwhvmhSEI-uuoa18rxwPmYZ8kI5Zjnk7Q6kIA,4383
|
|
6
|
+
artificial_intelligence_local/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
artificial_intelligence_local/tests/artificial_intelligence_local_test.py,sha256=howtqD2rHlK9Zg08JnyPmZgXz-d4o83Aikti8kQIlL4,344
|
|
8
|
+
artificial_intelligence_local/tests/contact_agent_test.py,sha256=DbeeANWnAYvy1iD77Z6miUU6XgVnyERHLDhlMFMIJzg,11132
|
|
9
|
+
artificial_intelligence_local/tests/ollama_agent_base_test.py,sha256=46JRjFmgCFNEEcPbmW4qS1cP_kad8Pr5ov5eP4ujI_Y,5744
|
|
10
|
+
artificial_intelligence_local/tests/weather_agent_test.py,sha256=EJ5agBiHNLOBWQsyjENqeAufhrytQI9t0IyU6vkBMVM,3088
|
|
11
|
+
artificial_intelligence_local-0.0.2b3115.dist-info/METADATA,sha256=D1nu3hFywtt2GubeBO-0wttwgF5sDx5UUFroKJusZAE,1042
|
|
12
|
+
artificial_intelligence_local-0.0.2b3115.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
artificial_intelligence_local-0.0.2b3115.dist-info/top_level.txt,sha256=QAJP8cTE3SKcbE58zw4EH_qDgZqVNktdTyEemkzGXoI,30
|
|
14
|
+
artificial_intelligence_local-0.0.2b3115.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
artificial_intelligence_local
|