artificial-intelligence-local 0.0.2b3115__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- artificial_intelligence_local-0.0.2b3115/PKG-INFO +29 -0
- artificial_intelligence_local-0.0.2b3115/README.md +150 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/__init__.py +0 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/src/__init__.py +0 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/src/contact_agent.py +264 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/src/ollama_agent_base.py +204 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/src/weather_agent.py +112 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/tests/__init__.py +0 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/tests/artificial_intelligence_local_test.py +10 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/tests/contact_agent_test.py +275 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/tests/ollama_agent_base_test.py +124 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/tests/weather_agent_test.py +83 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local.egg-info/PKG-INFO +29 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local.egg-info/SOURCES.txt +18 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local.egg-info/dependency_links.txt +1 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local.egg-info/requires.txt +7 -0
- artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local.egg-info/top_level.txt +1 -0
- artificial_intelligence_local-0.0.2b3115/pyproject.toml +29 -0
- artificial_intelligence_local-0.0.2b3115/setup.cfg +4 -0
- artificial_intelligence_local-0.0.2b3115/setup.py +84 -0
|
@@ -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,150 @@
|
|
|
1
|
+
# General
|
|
2
|
+
|
|
3
|
+
Unlike the README.md in the root directory, this is a README.md of this specific repo/package<br>
|
|
4
|
+
|
|
5
|
+
TODO Please update this README.md of this specific repo/package and not the README.md in the root directory based on the python-package-template repo<br>
|
|
6
|
+
|
|
7
|
+
## TODOs
|
|
8
|
+
|
|
9
|
+
### Implement using AWS Strands Agents SDK wrapper classes<br>
|
|
10
|
+
|
|
11
|
+
All the classes below should get an optional parameter of OurAgentSdkEnum, which at this point has only one value: AWS_STRANDS_AGENTS_SDK<br>
|
|
12
|
+
|
|
13
|
+
TODO Create OurLlmModel class, which at this point inherits "stands.models.litellm" LiteLLMModel. Has model_id data member<br>
|
|
14
|
+
TODO Create OurLlmModelWithSystemPrompt class which inherits OurLlmModel and has system_prompt data member<br>
|
|
15
|
+
TODO Create OurLlmPrompt class, which prompt str data member<br>
|
|
16
|
+
TODO Create OurLlmComplete method<br>
|
|
17
|
+
TODO Create OurLlmResponse class with which the same data memresponse str data member<br>
|
|
18
|
+
TODO Create OurLlmConversation class which includes Context<br>
|
|
19
|
+
|
|
20
|
+
TODO Create OurLlmAgent class with inheritance at this point stands Agent<br>
|
|
21
|
+
TODO Create OurLlmSession class, which at this point inherits strands.session.s3_session_manager S3SessionManager (it can recieve optional OurStorage parameter i.e. S3) uses our storage-local-python-package repo<br>
|
|
22
|
+
TODO Create OurLlmMemory (knowledgebase) Class which uses SmartVectordb class from our smart-datastore-python-package repo<br>
|
|
23
|
+
TODO Create OurLlmGuardrails class that imports strands.models BedrockModel and uses BedrockModel( guardrail_id and guardrail_version and guardrail_trace )<br>
|
|
24
|
+
TODO Create OurLlmMcpClient, which at this point inherits strands.tools.mcp MCPClient<br>
|
|
25
|
+
|
|
26
|
+
TODO Natural 2 KQL
|
|
27
|
+
TODO Natural 2 SQL
|
|
28
|
+
|
|
29
|
+
TODO Convert https://github.com/gilinachum/agents/blob/main/strands-beginner.ipynb to use OurLlmModel, OurLlmPrompt (prompt-local-python-package), OurLlmResponse, OurLlmConversation, OurLlmAgent, OurLlmSession, OurLlmMemory, OurLlmGuardrails in this repo<br>
|
|
30
|
+
TODO Implement https://github.com/microsoft/langchain-for-beginners in prompt-local-python-package using inheritance to allow also other implementations.<br>
|
|
31
|
+
|
|
32
|
+
TODO Develop OurLlmRouter based on OpenRouter, AWS Bedrock
|
|
33
|
+
|
|
34
|
+
TODO Wrapper around OpenRouter to gain API for Any AI Model
|
|
35
|
+
|
|
36
|
+
### Implement using OpenAI's Agent SDK
|
|
37
|
+
|
|
38
|
+
You can start by working with an OpenAI-compatible model running locally with LMStudio running locally<br>
|
|
39
|
+
|
|
40
|
+
### Implement using Google's Agent Development Kit (ADK)
|
|
41
|
+
|
|
42
|
+
### Implement using LangChain Agents
|
|
43
|
+
|
|
44
|
+
) using machine learning model, Smart Vector Store (smart_vectorstore.py)<br>
|
|
45
|
+
|
|
46
|
+
# Running the local weather agent
|
|
47
|
+
|
|
48
|
+
An example agent lives at `artificial_intelligence_local/src/weather_agent.py`. It runs a local
|
|
49
|
+
LLM via [Ollama](https://ollama.com) and answers US weather questions from the National Weather
|
|
50
|
+
Service API.
|
|
51
|
+
|
|
52
|
+
Following the tool-based pattern in the [Strands samples](https://github.com/strands-agents/samples)
|
|
53
|
+
repo, the National Weather Service two-step call (`points` → `forecast`) lives inside a Python
|
|
54
|
+
`get_forecast(latitude, longitude)` tool. The model's only job is to pick the location's
|
|
55
|
+
coordinates, call the tool, and phrase the one-line answer — which is far more reliable on small
|
|
56
|
+
local models than asking the model to build API URLs and parse JSON itself.
|
|
57
|
+
|
|
58
|
+
## Prerequisites
|
|
59
|
+
|
|
60
|
+
1. Install Ollama: https://ollama.com/download — then start it (the Ollama app, or `ollama serve`).
|
|
61
|
+
2. Pull a model that supports tool-calling, e.g.: `ollama pull qwen2.5:7b`
|
|
62
|
+
3. Copy `.env.local1.example` to `.env.local1` and set `OLLAMA_HOST` / `OLLAMA_MODEL_ID` for your machine.
|
|
63
|
+
|
|
64
|
+
## Run
|
|
65
|
+
|
|
66
|
+
Run these from the package directory. Note the repo nests a same-named folder, so from the
|
|
67
|
+
cloned repository root you first need to `cd` one level in:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# From the repository root:
|
|
71
|
+
cd artificial-intelligence-local-python-package # the inner package dir (skip if already there)
|
|
72
|
+
|
|
73
|
+
python -m venv .venv && source .venv/bin/activate
|
|
74
|
+
pip install -r requirements.txt
|
|
75
|
+
python artificial_intelligence_local/src/weather_agent.py
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
On startup the agent lists the Ollama models you have installed and lets you pick one by number
|
|
79
|
+
(press Enter to accept the default). Then ask, e.g., `What's the weather like in Seattle?` (type
|
|
80
|
+
`exit` to quit).
|
|
81
|
+
|
|
82
|
+
The pre-selected default comes from `.env.local1` (`OLLAMA_MODEL_ID`). `qwen2.5:7b` is the default
|
|
83
|
+
and handles the coordinate-lookup + tool-call reliably; smaller models like `llama3.2:3b` also work
|
|
84
|
+
but are more likely to guess coordinates poorly. In non-interactive runs (tests / CI) the default is
|
|
85
|
+
used without prompting.
|
|
86
|
+
|
|
87
|
+
# Running the local contact agent
|
|
88
|
+
|
|
89
|
+
`artificial_intelligence_local/src/contact_agent.py` is the same pattern applied to our contacts
|
|
90
|
+
database. Its tools wrap the read-only `_strand` twins published by
|
|
91
|
+
[contact-local](https://pypi.org/project/contact-local/) (`contact_local/contact_local_strands.py`),
|
|
92
|
+
so a local Ollama model can answer questions about contacts.
|
|
93
|
+
|
|
94
|
+
The `@tool` decorator lives here rather than in contact-main on purpose: contact-main stays free of
|
|
95
|
+
any Strands dependency, and this package decides how its functions are exposed to a model. The tools
|
|
96
|
+
take only flat scalar arguments — the strand twins speak `contact_dict`, but a small local model
|
|
97
|
+
cannot be trusted to synthesize a dict (it invents keys), so where an answer needs two strand calls
|
|
98
|
+
the tool composes them internally.
|
|
99
|
+
|
|
100
|
+
## What it can and cannot answer
|
|
101
|
+
|
|
102
|
+
Lookup is by **contact id, phone number, or email address**:
|
|
103
|
+
|
|
104
|
+
- `Who is contact 1?`
|
|
105
|
+
- `What's the phone number of contact 1?`
|
|
106
|
+
- `Do we already have someone with the email tal@circ.zone?`
|
|
107
|
+
|
|
108
|
+
It **cannot look up a contact by name** — `ContactsLocal` has no name-based lookup
|
|
109
|
+
(`get_existing_contact_dict` matches on phone/email only). Asked "What's Tal's phone number?", the
|
|
110
|
+
agent says it can only look someone up by id / phone / email rather than guessing an id. Adding name
|
|
111
|
+
lookup needs `get_contact_by_name` in contact-main first, which also has to handle first+last names
|
|
112
|
+
not being unique (BU-3058).
|
|
113
|
+
|
|
114
|
+
## Prerequisites
|
|
115
|
+
|
|
116
|
+
The Ollama prerequisites above, plus — because every tool call goes through contact-local to the
|
|
117
|
+
database — the local backend:
|
|
118
|
+
|
|
119
|
+
1. A local MySQL with the contact schema.
|
|
120
|
+
2. The local authentication server on `http://localhost:3000`: from
|
|
121
|
+
`authentication-local-restapi-typescript-serverless-com`, run `npx serverless offline --stage local`.
|
|
122
|
+
(Note `serverless.ts` calls a bare `dotenv.config()`, which only reads a file named exactly `.env`,
|
|
123
|
+
so the `RDS_*` / `JWT_*` variables have to be exported in your shell or copied into `.env`.)
|
|
124
|
+
|
|
125
|
+
Importing `contact_local` performs an auth login *at import time*, which is why `contact_agent.py`
|
|
126
|
+
imports it lazily inside the tools — the module and its tests import fine without any of this
|
|
127
|
+
running, and only an actual lookup needs it.
|
|
128
|
+
|
|
129
|
+
## Run
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# From the inner package directory, as above:
|
|
133
|
+
python artificial_intelligence_local/src/contact_agent.py
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Then ask, e.g., `What's the phone number of contact 1?` (type `exit` to quit).
|
|
137
|
+
|
|
138
|
+
# Versions
|
|
139
|
+
|
|
140
|
+
[pub] 0.0.1 Initial version (change the directories, files, setup.py, .github/workflows/*.yml)<br>
|
|
141
|
+
|
|
142
|
+
# Installing instructions
|
|
143
|
+
|
|
144
|
+
It's advised to use venv<br>
|
|
145
|
+
If you have Windows, it's also advised to use Windows Subsystem for Linux<br>
|
|
146
|
+
|
|
147
|
+
## Install dependencies
|
|
148
|
+
|
|
149
|
+
Run ./download-beta-sdk.sh from the bash terminal<br>
|
|
150
|
+
Run pip install -r requirements.txt from the bash terminal<br>
|
|
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
|
+
)
|
artificial_intelligence_local-0.0.2b3115/artificial_intelligence_local/src/ollama_agent_base.py
ADDED
|
@@ -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.")
|