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.
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ artificial_intelligence_local