compass-local 0.0.1__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.
- compass_local-0.0.1/PKG-INFO +27 -0
- compass_local-0.0.1/README.md +56 -0
- compass_local-0.0.1/compass_local/__init__.py +0 -0
- compass_local-0.0.1/compass_local/src/__init__.py +0 -0
- compass_local-0.0.1/compass_local/src/calendaring/__init__.py +5 -0
- compass_local-0.0.1/compass_local/src/calendaring/calendar_agent.py +97 -0
- compass_local-0.0.1/compass_local/src/calendaring/calendar_data_source.py +39 -0
- compass_local-0.0.1/compass_local/src/calendaring/calendar_reader.py +37 -0
- compass_local-0.0.1/compass_local/src/calendaring/calendar_seed_data.py +141 -0
- compass_local-0.0.1/compass_local/src/calendaring/calendar_tools.py +126 -0
- compass_local-0.0.1/compass_local/src/calendaring/event_profile_data_source.py +118 -0
- compass_local-0.0.1/compass_local/src/calendaring/event_profiles_local.py +125 -0
- compass_local-0.0.1/compass_local/src/calendaring/fixture_data_source.py +55 -0
- compass_local-0.0.1/compass_local/src/calendaring/generic_crud_calendar_reader.py +156 -0
- compass_local-0.0.1/compass_local/src/constants.py +35 -0
- compass_local-0.0.1/compass_local/src/contacts/__init__.py +8 -0
- compass_local-0.0.1/compass_local/src/contacts/agent.py +104 -0
- compass_local-0.0.1/compass_local/src/contacts/data_source.py +71 -0
- compass_local-0.0.1/compass_local/src/contacts/seed_data.py +92 -0
- compass_local-0.0.1/compass_local/src/contacts/tools.py +127 -0
- compass_local-0.0.1/compass_local/src/core/__init__.py +7 -0
- compass_local-0.0.1/compass_local/src/core/agent.py +27 -0
- compass_local-0.0.1/compass_local/src/core/agent_response.py +32 -0
- compass_local-0.0.1/compass_local/src/core/agent_return_code.py +17 -0
- compass_local-0.0.1/compass_local/src/core/llm_client.py +15 -0
- compass_local-0.0.1/compass_local/src/core/routing_agent.py +113 -0
- compass_local-0.0.1/compass_local/src/version.py +1 -0
- compass_local-0.0.1/compass_local/tests/__init__.py +0 -0
- compass_local-0.0.1/compass_local/tests/calendar_agent_test.py +465 -0
- compass_local-0.0.1/compass_local/tests/calendar_tools_test.py +152 -0
- compass_local-0.0.1/compass_local/tests/conftest.py +18 -0
- compass_local-0.0.1/compass_local/tests/contacts_agent_test.py +224 -0
- compass_local-0.0.1/compass_local/tests/contacts_data_source_test.py +38 -0
- compass_local-0.0.1/compass_local/tests/contacts_tools_test.py +240 -0
- compass_local-0.0.1/compass_local/tests/event_profile_data_source_test.py +187 -0
- compass_local-0.0.1/compass_local/tests/event_profiles_local_test.py +18 -0
- compass_local-0.0.1/compass_local/tests/fixture_data_source_test.py +38 -0
- compass_local-0.0.1/compass_local/tests/generic_crud_calendar_reader_test.py +235 -0
- compass_local-0.0.1/compass_local/tests/smoke_test.py +12 -0
- compass_local-0.0.1/compass_local.egg-info/PKG-INFO +27 -0
- compass_local-0.0.1/compass_local.egg-info/SOURCES.txt +45 -0
- compass_local-0.0.1/compass_local.egg-info/dependency_links.txt +1 -0
- compass_local-0.0.1/compass_local.egg-info/requires.txt +5 -0
- compass_local-0.0.1/compass_local.egg-info/top_level.txt +1 -0
- compass_local-0.0.1/pyproject.toml +30 -0
- compass_local-0.0.1/setup.cfg +4 -0
- compass_local-0.0.1/setup.py +47 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: compass-local
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: PyPI compass-local Python Package owned by Circlez.ai
|
|
5
|
+
Home-page: https://github.com/circles-zone/compass-local-python-package
|
|
6
|
+
Author: Circles
|
|
7
|
+
Author-email: info@circlez.ai
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: python-sdk-remote
|
|
12
|
+
Requires-Dist: logger-local
|
|
13
|
+
Requires-Dist: database-mysql-local
|
|
14
|
+
Requires-Dist: contact-profile-local
|
|
15
|
+
Requires-Dist: contact-group-local
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: author-email
|
|
18
|
+
Dynamic: classifier
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: requires-dist
|
|
23
|
+
Dynamic: summary
|
|
24
|
+
|
|
25
|
+
PyPI compass-local Python Package owned by Circlez.ai
|
|
26
|
+
Compass: multi-agent personal-assistant system (local package).
|
|
27
|
+
GHA: https://github.com/circles-zone/compass-local-python-package/actions
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# compass-local
|
|
2
|
+
|
|
3
|
+
**Compass** is a multi-agent personal-assistant system built over a person's data
|
|
4
|
+
(events/calendar, contacts, tasks, communications, campaigns). "Compass" is the
|
|
5
|
+
name of the program as a whole; `compass-local` is its **local package** — the
|
|
6
|
+
direct database-access layer (tools + agents) that the assistant is built on.
|
|
7
|
+
|
|
8
|
+
The system is organized as specialist agents, each owning one data domain, plus
|
|
9
|
+
synthesis and orchestration agents on top. Development starts with the **Calendar
|
|
10
|
+
agent** (events I'm attending, people I'm going to meet, face-to-face vs. remote).
|
|
11
|
+
|
|
12
|
+
## Structure
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
compass-local-python-package/ # repo directory (same name as the repo)
|
|
16
|
+
compass_local/ # the package
|
|
17
|
+
src/ # source: tools, agents, constants, version
|
|
18
|
+
tests/ # pytest unit/system tests
|
|
19
|
+
database/ # MySQL schema / views / test-data scripts
|
|
20
|
+
setup.py pytest.ini pyproject.toml requirements*.txt .flake8
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Install (development)
|
|
24
|
+
|
|
25
|
+
Use a virtualenv. On Windows, WSL is recommended.
|
|
26
|
+
|
|
27
|
+
```shell
|
|
28
|
+
python -m venv venv
|
|
29
|
+
pip install -r requirements.txt
|
|
30
|
+
pip install -r requirements-dev.txt
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Test
|
|
34
|
+
|
|
35
|
+
```shell
|
|
36
|
+
pytest
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Tests run without a live database where possible: `tests/conftest.py` sets safe
|
|
40
|
+
env defaults and skips tests that need a MySQL host when none is reachable.
|
|
41
|
+
|
|
42
|
+
## Lint (mandatory before push)
|
|
43
|
+
|
|
44
|
+
```shell
|
|
45
|
+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
46
|
+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Contributing / workflow
|
|
50
|
+
|
|
51
|
+
Work on a `BU-*` feature branch, keep the feature-branch GitHub Actions workflow
|
|
52
|
+
green with no warnings and coverage above 90%, then open a Pull Request into `dev`.
|
|
53
|
+
|
|
54
|
+
## Versions
|
|
55
|
+
|
|
56
|
+
- 0.0.1 — initial Compass package setup (structure, config, calendar agent).
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Calendar agent -- a thin RoutingAgent configured for calendar questions.
|
|
2
|
+
|
|
3
|
+
All the LLM-routing plumbing (prompt build, JSON parse, guarded dispatch, and the
|
|
4
|
+
bad-JSON / unknown-tool / bad-args robustness) lives once in core.routing_agent.
|
|
5
|
+
This module only supplies the calendar specifics: the tool catalog with its
|
|
6
|
+
per-tool result formatters, the CalendarTools binding, and the router's role line.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from core.routing_agent import RoutingAgent
|
|
10
|
+
from calendaring.calendar_tools import CalendarTools
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _format_events(events: list[dict]) -> str:
|
|
14
|
+
if not events:
|
|
15
|
+
return "No events found."
|
|
16
|
+
lines = []
|
|
17
|
+
for event in events:
|
|
18
|
+
# Prefer the display name, then name/title. These are the flat-contract
|
|
19
|
+
# keys the tools emit; calendar_reader maps them from the real schema
|
|
20
|
+
# (start_datetime <- start_timestamp, location_type <- location view).
|
|
21
|
+
label = (event.get("display_as") or event.get("name")
|
|
22
|
+
or event.get("title") or f"event {event.get('event_id')}")
|
|
23
|
+
start = event.get("start_datetime") or ""
|
|
24
|
+
location = event.get("location_type")
|
|
25
|
+
suffix = f" [{location}]" if location else ""
|
|
26
|
+
lines.append(f"- {label} ({start}){suffix}".rstrip())
|
|
27
|
+
return "\n".join(lines)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _format_contacts(contacts: list[dict]) -> str:
|
|
31
|
+
if not contacts:
|
|
32
|
+
return "No contacts found."
|
|
33
|
+
lines = []
|
|
34
|
+
for contact in contacts:
|
|
35
|
+
label = (contact.get("display_as") or contact.get("name")
|
|
36
|
+
or f"contact {contact.get('contact_id')}")
|
|
37
|
+
extras = [x for x in (contact.get("role"), contact.get("email1")) if x]
|
|
38
|
+
suffix = f" ({', '.join(extras)})" if extras else ""
|
|
39
|
+
lines.append(f"- {label}{suffix}")
|
|
40
|
+
return "\n".join(lines)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# name -> tool description (for the routing prompt) + result formatter. Keys are
|
|
44
|
+
# the exact CalendarTools method names, so dispatch is a guarded getattr.
|
|
45
|
+
_CATALOG: dict[str, dict] = {
|
|
46
|
+
"get_events_today": {
|
|
47
|
+
"desc": "events on a given day; args: today (ISO date, optional).",
|
|
48
|
+
"format": _format_events,
|
|
49
|
+
},
|
|
50
|
+
"get_events": {
|
|
51
|
+
"desc": "events overlapping a datetime range; args: range_start, range_end (ISO-8601).",
|
|
52
|
+
"format": _format_events,
|
|
53
|
+
},
|
|
54
|
+
"get_face_to_face_events": {
|
|
55
|
+
"desc": "in-person events in a range; args: range_start, range_end.",
|
|
56
|
+
"format": _format_events,
|
|
57
|
+
},
|
|
58
|
+
"get_remote_events": {
|
|
59
|
+
"desc": "remote events in a range; args: range_start, range_end.",
|
|
60
|
+
"format": _format_events,
|
|
61
|
+
},
|
|
62
|
+
"get_people_i_will_meet": {
|
|
63
|
+
"desc": "distinct contacts attending any event in a range; args: range_start, range_end.",
|
|
64
|
+
"format": _format_contacts,
|
|
65
|
+
},
|
|
66
|
+
"get_event_attendees": {
|
|
67
|
+
"desc": "contacts attending an event, with their role; args: event_id (int).",
|
|
68
|
+
"format": _format_contacts,
|
|
69
|
+
},
|
|
70
|
+
"get_meeting_followups": {
|
|
71
|
+
"desc": "people to follow up with after a meeting; args: event_id (int).",
|
|
72
|
+
"format": _format_contacts,
|
|
73
|
+
},
|
|
74
|
+
"get_contacts_in_group": {
|
|
75
|
+
"desc": "contacts in a named group, e.g. 2Call1; args: group_name (str).",
|
|
76
|
+
"format": _format_contacts,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_ROLE = "You are the Calendar agent's router."
|
|
81
|
+
# English renderings of the failure codes; the code is the contract, not these.
|
|
82
|
+
_NO_TOOL_MESSAGE = "Sorry, I couldn't map that to a calendar action."
|
|
83
|
+
_BAD_CALL_MESSAGE = "Sorry, I couldn't run that calendar action with those details."
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CalendarAgent(RoutingAgent):
|
|
87
|
+
"""Answers natural-language calendar questions by routing to one tool."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, llm, tools: CalendarTools | None = None) -> None:
|
|
90
|
+
super().__init__(
|
|
91
|
+
llm,
|
|
92
|
+
tools if tools is not None else CalendarTools(),
|
|
93
|
+
catalog=_CATALOG,
|
|
94
|
+
role=_ROLE,
|
|
95
|
+
no_tool_message=_NO_TOOL_MESSAGE,
|
|
96
|
+
bad_call_message=_BAD_CALL_MESSAGE,
|
|
97
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The seam between the Calendar tools and wherever the data physically lives.
|
|
2
|
+
|
|
3
|
+
Implementations return RAW ROWS ONLY -- no filtering, joining or date logic.
|
|
4
|
+
That logic lives in the tools, so it is written and tested exactly once no
|
|
5
|
+
matter which source is plugged in.
|
|
6
|
+
|
|
7
|
+
Known implementations:
|
|
8
|
+
- ``FixtureDataSource`` -> the bundled sample dataset (fixture_data_source.py)
|
|
9
|
+
- ``EventProfileDataSource`` -> the real normalized model (event_profile_data_source.py)
|
|
10
|
+
|
|
11
|
+
The tools depend on this interface, never on a concrete source, so swapping the
|
|
12
|
+
source touches only the edges.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CalendarDataSource(ABC):
|
|
19
|
+
"""Interface every calendar data source must satisfy.
|
|
20
|
+
|
|
21
|
+
Each method returns a list of row dicts using the real column names (see the
|
|
22
|
+
package README / data model).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def get_events(self) -> list[dict]:
|
|
27
|
+
"""All event rows (event_table columns + datetime/location)."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def get_contacts(self) -> list[dict]:
|
|
31
|
+
"""All contact rows (contact_table columns)."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def get_event_contacts(self) -> list[dict]:
|
|
35
|
+
"""Event<->contact links: {event_id, contact_id, role}."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def get_group_memberships(self) -> list[dict]:
|
|
39
|
+
"""Contact group links: {contact_id, group_name}."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The narrow read port over the normalized circlez tables.
|
|
2
|
+
|
|
3
|
+
Each method returns raw row dicts using the real column names; it performs no
|
|
4
|
+
joins or flattening -- that is the adapter's job (event_profile_data_source.py).
|
|
5
|
+
Keeping the port this thin is what lets all the join/flatten/role/datetime logic
|
|
6
|
+
be unit-tested without a database.
|
|
7
|
+
|
|
8
|
+
The concrete implementation that talks to the real circlez packages is
|
|
9
|
+
``GenericCrudCalendarReader`` (generic_crud_calendar_reader.py).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CalendarReader(Protocol):
|
|
16
|
+
"""Narrow read port over the normalized tables.
|
|
17
|
+
|
|
18
|
+
``to_registered_profile_id`` is normalized here (the underlying view exposes
|
|
19
|
+
it as ``to.registered_profile_id``) so the adapter logic can stay clean.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def fetch_events(self) -> list[dict]:
|
|
23
|
+
"""event_view rows for the user: event_id, start_datetime, end_datetime,
|
|
24
|
+
location_type, day/month/year, and the rest of the event columns."""
|
|
25
|
+
|
|
26
|
+
def fetch_contacts(self) -> list[dict]:
|
|
27
|
+
"""contact_view rows: contact_id, display_as, email1, phone1, ..."""
|
|
28
|
+
|
|
29
|
+
def fetch_event_profiles(self) -> list[dict]:
|
|
30
|
+
"""event_profile_view rows: event_id, to_registered_profile_id, and the
|
|
31
|
+
is_* state flags (is_checked_in, is_will_arrive, is_admin, is_invited)."""
|
|
32
|
+
|
|
33
|
+
def fetch_contact_profiles(self) -> list[dict]:
|
|
34
|
+
"""contact_profile_view rows: contact_id, profile_id."""
|
|
35
|
+
|
|
36
|
+
def fetch_contact_group_memberships(self) -> list[dict]:
|
|
37
|
+
"""Contact-group rows already resolved to a name: contact_id, group_name."""
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Bundled sample dataset for Compass (invented test/demo data).
|
|
2
|
+
|
|
3
|
+
Rows are in the flat shape the deterministic tools consume; the real adapter maps
|
|
4
|
+
the normalized model onto this same shape. Field names follow the installed
|
|
5
|
+
schema where they overlap:
|
|
6
|
+
- contacts -> contact_table (contact-local): contact_id, first_name, last_name,
|
|
7
|
+
display_as, email1, phone1, updated_timestamp.
|
|
8
|
+
- events -> event_view (event package): event_id, name, organizers_profile_id,
|
|
9
|
+
location_id. The tools read start_datetime / end_datetime / location_type,
|
|
10
|
+
which the real reader derives from event_view.start_timestamp / end_timestamp
|
|
11
|
+
and the location_type model; here they are provided directly.
|
|
12
|
+
|
|
13
|
+
CALENDAR_SEED_EVENT_CONTACTS and CALENDAR_SEED_GROUP_MEMBERSHIPS are flat stand-ins for the profile-mediated
|
|
14
|
+
attendee chain and the contact-group mapping (the reader produces the same rows
|
|
15
|
+
from the real tables).
|
|
16
|
+
|
|
17
|
+
All rows carry is_test_data=1 so they are trivially separable from real data.
|
|
18
|
+
The user the assistant serves is organizers_profile_id = 1.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
CALENDAR_SEED_USER_PROFILE_ID = 1
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# contact_table (real columns; audit columns omitted for readability)
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
CALENDAR_SEED_CONTACTS = [
|
|
27
|
+
{"contact_id": 3001, "first_name": "Dana", "last_name": "Levi",
|
|
28
|
+
"display_as": "Dana Levi", "email1": "dana@acme.vc",
|
|
29
|
+
"phone1": "+972-50-1112222", "updated_timestamp": "2026-07-01T09:00:00",
|
|
30
|
+
"is_test_data": 1},
|
|
31
|
+
{"contact_id": 3002, "first_name": "Omer", "last_name": "Cohen",
|
|
32
|
+
"display_as": "Omer Cohen", "email1": "omer@beta.io",
|
|
33
|
+
"phone1": "+972-52-3334444", "updated_timestamp": "2026-06-28T14:00:00",
|
|
34
|
+
"is_test_data": 1},
|
|
35
|
+
{"contact_id": 3003, "first_name": "Yael", "last_name": "Barak",
|
|
36
|
+
"display_as": "Yael Barak", "email1": "yael@northwind.com",
|
|
37
|
+
"phone1": "+972-54-5556666", "updated_timestamp": "2026-07-04T11:30:00",
|
|
38
|
+
"is_test_data": 1},
|
|
39
|
+
{"contact_id": 3004, "first_name": "Noa", "last_name": "Friedman",
|
|
40
|
+
"display_as": "Noa Friedman", "email1": "noa@zenith.co",
|
|
41
|
+
"phone1": "+972-58-7778888", "updated_timestamp": "2026-07-05T08:15:00",
|
|
42
|
+
"is_test_data": 1},
|
|
43
|
+
{"contact_id": 3005, "first_name": "Itai", "last_name": "Shapira",
|
|
44
|
+
"display_as": "Itai Shapira", "email1": "itai@quanta.ai",
|
|
45
|
+
"phone1": "+972-53-9990000", "updated_timestamp": "2026-06-20T16:45:00",
|
|
46
|
+
"is_test_data": 1},
|
|
47
|
+
{"contact_id": 3006, "first_name": "Maya", "last_name": "Gold",
|
|
48
|
+
"display_as": "Maya Gold", "email1": "maya@lumen.dev",
|
|
49
|
+
"phone1": "+972-50-2223333", "updated_timestamp": "2026-07-06T07:00:00",
|
|
50
|
+
"is_test_data": 1},
|
|
51
|
+
{"contact_id": 3007, "first_name": "Eitan", "last_name": "Peretz",
|
|
52
|
+
"display_as": "Eitan Peretz", "email1": "eitan@orbit.vc",
|
|
53
|
+
"phone1": "+972-52-4445555", "updated_timestamp": "2026-05-30T10:00:00",
|
|
54
|
+
"is_test_data": 1},
|
|
55
|
+
{"contact_id": 3008, "first_name": "Tamar", "last_name": "Azoulay",
|
|
56
|
+
"display_as": "Tamar Azoulay", "email1": "tamar@delta.io",
|
|
57
|
+
"phone1": "+972-54-6667777", "updated_timestamp": "2026-07-03T13:20:00",
|
|
58
|
+
"is_test_data": 1},
|
|
59
|
+
{"contact_id": 3009, "first_name": "Roi", "last_name": "Mizrahi",
|
|
60
|
+
"display_as": "Roi Mizrahi", "email1": "roi@sigma.com",
|
|
61
|
+
"phone1": "+972-58-8889999", "updated_timestamp": "2026-06-15T09:45:00",
|
|
62
|
+
"is_test_data": 1},
|
|
63
|
+
{"contact_id": 3010, "first_name": "Shira", "last_name": "Katz",
|
|
64
|
+
"display_as": "Shira Katz", "email1": "shira@vega.co",
|
|
65
|
+
"phone1": "+972-53-1114444", "updated_timestamp": "2026-07-02T15:00:00",
|
|
66
|
+
"is_test_data": 1},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# events (event_view columns: event_id, name, organizers_profile_id, location_id)
|
|
71
|
+
# start_datetime / end_datetime / location_type are the flat-contract fields the
|
|
72
|
+
# tools read -- the real reader derives them from start_timestamp / end_timestamp
|
|
73
|
+
# and the location_type model. Dates span "today" = 2026-07-06 and the
|
|
74
|
+
# surrounding days, so "events today" and "this week" both have coverage.
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
CALENDAR_SEED_EVENTS = [
|
|
77
|
+
{"event_id": 9001, "name": "Coffee with Dana - partnership intro",
|
|
78
|
+
"organizers_profile_id": 1, "location_id": 4101,
|
|
79
|
+
"start_datetime": "2026-07-06T10:00:00", "end_datetime": "2026-07-06T10:45:00",
|
|
80
|
+
"location_type": "in_person", "is_test_data": 1},
|
|
81
|
+
|
|
82
|
+
{"event_id": 9002, "name": "Beta.io product sync",
|
|
83
|
+
"organizers_profile_id": 1, "location_id": 4102,
|
|
84
|
+
"start_datetime": "2026-07-06T14:00:00", "end_datetime": "2026-07-06T14:30:00",
|
|
85
|
+
"location_type": "remote", "is_test_data": 1},
|
|
86
|
+
|
|
87
|
+
{"event_id": 9003, "name": "Northwind onboarding call",
|
|
88
|
+
"organizers_profile_id": 1, "location_id": 4103,
|
|
89
|
+
"start_datetime": "2026-07-07T09:30:00", "end_datetime": "2026-07-07T10:15:00",
|
|
90
|
+
"location_type": "remote", "is_test_data": 1},
|
|
91
|
+
|
|
92
|
+
{"event_id": 9004, "name": "Lunch with Itai & Maya",
|
|
93
|
+
"organizers_profile_id": 1, "location_id": 4104,
|
|
94
|
+
"start_datetime": "2026-07-08T12:30:00", "end_datetime": "2026-07-08T13:30:00",
|
|
95
|
+
"location_type": "in_person", "is_test_data": 1},
|
|
96
|
+
|
|
97
|
+
{"event_id": 9005, "name": "AI Circles meetup - networking",
|
|
98
|
+
"organizers_profile_id": 1, "location_id": 4105,
|
|
99
|
+
"start_datetime": "2026-07-09T18:00:00", "end_datetime": "2026-07-09T21:00:00",
|
|
100
|
+
"location_type": "in_person", "is_test_data": 1},
|
|
101
|
+
|
|
102
|
+
{"event_id": 9006, "name": "Delta.io contract review",
|
|
103
|
+
"organizers_profile_id": 1, "location_id": 4106,
|
|
104
|
+
"start_datetime": "2026-07-06T16:30:00", "end_datetime": "2026-07-06T17:00:00",
|
|
105
|
+
"location_type": "remote", "is_test_data": 1},
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# PROPOSED link table: event_contact_table (who attends which event)
|
|
110
|
+
# role is calendar-standard attendance state (Google Calendar responseStatus /
|
|
111
|
+
# iCalendar PARTSTAT, plus organizer/checked_in): one of
|
|
112
|
+
# {"organizer", "checked_in", "accepted", "tentative", "declined", "needs_action"}.
|
|
113
|
+
# The real adapter derives the same values from the event_profile is_* flags.
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
CALENDAR_SEED_EVENT_CONTACTS = [
|
|
116
|
+
{"event_id": 9001, "contact_id": 3001, "role": "accepted"},
|
|
117
|
+
{"event_id": 9002, "contact_id": 3002, "role": "accepted"},
|
|
118
|
+
{"event_id": 9003, "contact_id": 3003, "role": "needs_action"},
|
|
119
|
+
{"event_id": 9004, "contact_id": 3005, "role": "accepted"},
|
|
120
|
+
{"event_id": 9004, "contact_id": 3006, "role": "tentative"},
|
|
121
|
+
{"event_id": 9005, "contact_id": 3004, "role": "organizer"},
|
|
122
|
+
{"event_id": 9005, "contact_id": 3007, "role": "checked_in"},
|
|
123
|
+
{"event_id": 9005, "contact_id": 3010, "role": "accepted"},
|
|
124
|
+
{"event_id": 9006, "contact_id": 3008, "role": "declined"},
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# PROPOSED link table: contact_group_membership
|
|
129
|
+
# Groups are the challenge's target groups: 2Call1..3, 2Contact1..3
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
CALENDAR_SEED_GROUP_MEMBERSHIPS = [
|
|
132
|
+
{"contact_id": 3001, "group_name": "2Call1"},
|
|
133
|
+
{"contact_id": 3003, "group_name": "2Call1"},
|
|
134
|
+
{"contact_id": 3002, "group_name": "2Call2"},
|
|
135
|
+
{"contact_id": 3005, "group_name": "2Call3"},
|
|
136
|
+
{"contact_id": 3004, "group_name": "2Contact1"},
|
|
137
|
+
{"contact_id": 3006, "group_name": "2Contact1"},
|
|
138
|
+
{"contact_id": 3007, "group_name": "2Contact2"},
|
|
139
|
+
{"contact_id": 3009, "group_name": "2Contact3"},
|
|
140
|
+
{"contact_id": 3010, "group_name": "2Contact3"},
|
|
141
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Deterministic Calendar tools.
|
|
2
|
+
|
|
3
|
+
Thin, no-LLM functions over a CalendarDataSource. Each is pure and independently
|
|
4
|
+
unit-testable: given the same data it returns the same result, with no network,
|
|
5
|
+
no database, and no model calls. The agent layer (added later) decides which of
|
|
6
|
+
these to call for a natural-language request; these tools just answer.
|
|
7
|
+
|
|
8
|
+
Date handling uses the standard library only, so the tools are testable without
|
|
9
|
+
the private circlez packages. Each event's start/end is taken from the PROPOSED
|
|
10
|
+
start_datetime/end_datetime when present, else derived from the provided
|
|
11
|
+
day/month/year columns -- so the tools work against the real event_table too.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from datetime import date, datetime
|
|
15
|
+
|
|
16
|
+
from calendaring.calendar_data_source import CalendarDataSource
|
|
17
|
+
from calendaring.fixture_data_source import FixtureDataSource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_datetime(value) -> datetime:
|
|
21
|
+
"""Accept a datetime or an ISO-8601 string and return a datetime."""
|
|
22
|
+
if isinstance(value, datetime):
|
|
23
|
+
return value
|
|
24
|
+
return datetime.fromisoformat(value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _event_start(event: dict) -> datetime:
|
|
28
|
+
if event.get("start_datetime"):
|
|
29
|
+
return datetime.fromisoformat(event["start_datetime"])
|
|
30
|
+
return datetime(event["year"], event["month"], event["day"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _event_end(event: dict) -> datetime:
|
|
34
|
+
if event.get("end_datetime"):
|
|
35
|
+
return datetime.fromisoformat(event["end_datetime"])
|
|
36
|
+
start = _event_start(event)
|
|
37
|
+
return start.replace(hour=23, minute=59, second=59)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CalendarTools:
|
|
41
|
+
"""Calendar-domain tools bound to a single data source."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, source: CalendarDataSource | None = None) -> None:
|
|
44
|
+
self._source = source if source is not None else FixtureDataSource()
|
|
45
|
+
|
|
46
|
+
def _contacts_by_id(self) -> dict:
|
|
47
|
+
return {c["contact_id"]: c for c in self._source.get_contacts()}
|
|
48
|
+
|
|
49
|
+
def get_events(self, range_start, range_end) -> list[dict]:
|
|
50
|
+
"""Events whose time overlaps [range_start, range_end], sorted by start."""
|
|
51
|
+
start = _to_datetime(range_start)
|
|
52
|
+
end = _to_datetime(range_end)
|
|
53
|
+
events = [
|
|
54
|
+
event
|
|
55
|
+
for event in self._source.get_events()
|
|
56
|
+
if _event_start(event) <= end and _event_end(event) >= start
|
|
57
|
+
]
|
|
58
|
+
return sorted(events, key=_event_start)
|
|
59
|
+
|
|
60
|
+
def get_events_today(self, today=None) -> list[dict]:
|
|
61
|
+
"""Events on a given day (defaults to the real current date)."""
|
|
62
|
+
day = today if today is not None else date.today()
|
|
63
|
+
if isinstance(day, str):
|
|
64
|
+
day = date.fromisoformat(day)
|
|
65
|
+
start = datetime(day.year, day.month, day.day, 0, 0, 0)
|
|
66
|
+
end = datetime(day.year, day.month, day.day, 23, 59, 59)
|
|
67
|
+
return self.get_events(start, end)
|
|
68
|
+
|
|
69
|
+
def get_event_attendees(self, event_id) -> list[dict]:
|
|
70
|
+
"""Contacts linked to an event, each annotated with its role."""
|
|
71
|
+
contacts_by_id = self._contacts_by_id()
|
|
72
|
+
attendees = []
|
|
73
|
+
for link in self._source.get_event_contacts():
|
|
74
|
+
if link["event_id"] == event_id:
|
|
75
|
+
contact = contacts_by_id.get(link["contact_id"])
|
|
76
|
+
if contact is not None:
|
|
77
|
+
attendees.append({**contact, "role": link.get("role")})
|
|
78
|
+
return attendees
|
|
79
|
+
|
|
80
|
+
def get_people_i_will_meet(self, range_start, range_end) -> list[dict]:
|
|
81
|
+
"""Distinct contacts attending any event in the range."""
|
|
82
|
+
event_ids = {event["event_id"] for event in self.get_events(range_start, range_end)}
|
|
83
|
+
contacts_by_id = self._contacts_by_id()
|
|
84
|
+
met: dict = {}
|
|
85
|
+
for link in self._source.get_event_contacts():
|
|
86
|
+
contact_id = link["contact_id"]
|
|
87
|
+
if link["event_id"] in event_ids and contact_id not in met:
|
|
88
|
+
contact = contacts_by_id.get(contact_id)
|
|
89
|
+
if contact is not None:
|
|
90
|
+
met[contact_id] = contact
|
|
91
|
+
return list(met.values())
|
|
92
|
+
|
|
93
|
+
def get_face_to_face_events(self, range_start, range_end) -> list[dict]:
|
|
94
|
+
"""In-person events in the range."""
|
|
95
|
+
return [
|
|
96
|
+
event
|
|
97
|
+
for event in self.get_events(range_start, range_end)
|
|
98
|
+
if event.get("location_type") == "in_person"
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
def get_remote_events(self, range_start, range_end) -> list[dict]:
|
|
102
|
+
"""Remote events in the range."""
|
|
103
|
+
return [
|
|
104
|
+
event
|
|
105
|
+
for event in self.get_events(range_start, range_end)
|
|
106
|
+
if event.get("location_type") == "remote"
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def get_contacts_in_group(self, group_name) -> list[dict]:
|
|
110
|
+
"""Contacts belonging to a named group (e.g. 2Call1, 2Contact2)."""
|
|
111
|
+
contacts_by_id = self._contacts_by_id()
|
|
112
|
+
members = []
|
|
113
|
+
for membership in self._source.get_group_memberships():
|
|
114
|
+
if membership["group_name"] == group_name:
|
|
115
|
+
contact = contacts_by_id.get(membership["contact_id"])
|
|
116
|
+
if contact is not None:
|
|
117
|
+
members.append(contact)
|
|
118
|
+
return members
|
|
119
|
+
|
|
120
|
+
def get_meeting_followups(self, event_id) -> list[dict]:
|
|
121
|
+
"""People to follow up with after an event (its linked contacts + roles).
|
|
122
|
+
|
|
123
|
+
Same linkage as get_event_attendees, exposed under a follow-up name so
|
|
124
|
+
the agent can answer "who should I follow up with from meeting X".
|
|
125
|
+
"""
|
|
126
|
+
return self.get_event_attendees(event_id)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Database-backed Compass calendar data source (the real adapter).
|
|
2
|
+
|
|
3
|
+
Turns the normalized circlez data model into the SAME flat rows the
|
|
4
|
+
deterministic tools already consume, so the tools and their tests never change.
|
|
5
|
+
|
|
6
|
+
All the join / flatten / role / datetime logic lives here as pure Python over the
|
|
7
|
+
narrow ``CalendarReader`` port (calendar_reader.py), so it is fully unit-testable
|
|
8
|
+
without a database. The concrete port that talks to the real circlez packages
|
|
9
|
+
lives in generic_crud_calendar_reader.py and is import-isolated there, because
|
|
10
|
+
those classes open a MySQL connection at construction time and are not importable
|
|
11
|
+
in a DB-free test environment.
|
|
12
|
+
|
|
13
|
+
The attendee chain (confirmed against the provided SQL views) is:
|
|
14
|
+
event_profile_table (event_id, to_registered_profile_id, is_* flags)
|
|
15
|
+
-> contact_profile_table (profile_id -> contact_id)
|
|
16
|
+
-> contact_table [= the attendee]
|
|
17
|
+
Groups: contact_group_table (contact_id, group_id) -> group name (title).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from datetime import date, datetime
|
|
21
|
+
|
|
22
|
+
from calendaring.calendar_data_source import CalendarDataSource
|
|
23
|
+
from calendaring.calendar_reader import CalendarReader
|
|
24
|
+
|
|
25
|
+
# event_profile is_* flags mapped to a calendar-standard role, in precedence
|
|
26
|
+
# order; the first truthy flag wins. The vocabulary matches Google Calendar's
|
|
27
|
+
# responseStatus / iCalendar PARTSTAT, extended with organizer and checked_in,
|
|
28
|
+
# and is the same set the sample data uses. Roles describe attendance STATE --
|
|
29
|
+
# the axis the real schema supports; there is no lecturer/exhibitor flag.
|
|
30
|
+
_ROLE_BY_FLAG = (
|
|
31
|
+
("is_admin", "organizer"),
|
|
32
|
+
("is_checked_in", "checked_in"),
|
|
33
|
+
("is_will_arrive", "accepted"),
|
|
34
|
+
("is_tentative", "tentative"),
|
|
35
|
+
("is_will_not_arrive", "declined"),
|
|
36
|
+
("is_not_interested", "declined"),
|
|
37
|
+
("is_invited", "needs_action"),
|
|
38
|
+
)
|
|
39
|
+
_DEFAULT_ROLE = "needs_action"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _role_from_event_profile(event_profile: dict) -> str:
|
|
43
|
+
"""Derive a role string from the event_profile state flags."""
|
|
44
|
+
for flag, role in _ROLE_BY_FLAG:
|
|
45
|
+
if event_profile.get(flag):
|
|
46
|
+
return role
|
|
47
|
+
return _DEFAULT_ROLE
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _as_iso(value):
|
|
51
|
+
"""Render a datetime/date as an ISO-8601 string; pass other values through.
|
|
52
|
+
|
|
53
|
+
The tools read start/end via ``datetime.fromisoformat`` on a string, so DB
|
|
54
|
+
DATETIME values (returned as datetime objects) must be stringified here to
|
|
55
|
+
keep the tools unchanged."""
|
|
56
|
+
if isinstance(value, (datetime, date)):
|
|
57
|
+
return value.isoformat()
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EventProfileDataSource(CalendarDataSource):
|
|
62
|
+
"""CalendarDataSource backed by the normalized circlez model, reached through
|
|
63
|
+
a CalendarReader. Emits the same flat rows as FixtureDataSource."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, reader: CalendarReader) -> None:
|
|
66
|
+
self._reader = reader
|
|
67
|
+
|
|
68
|
+
def get_events(self) -> list[dict]:
|
|
69
|
+
events = []
|
|
70
|
+
for row in self._reader.fetch_events():
|
|
71
|
+
event = dict(row)
|
|
72
|
+
if "start_datetime" in event:
|
|
73
|
+
event["start_datetime"] = _as_iso(event["start_datetime"])
|
|
74
|
+
if "end_datetime" in event:
|
|
75
|
+
event["end_datetime"] = _as_iso(event["end_datetime"])
|
|
76
|
+
events.append(event)
|
|
77
|
+
return events
|
|
78
|
+
|
|
79
|
+
def get_contacts(self) -> list[dict]:
|
|
80
|
+
return [dict(row) for row in self._reader.fetch_contacts()]
|
|
81
|
+
|
|
82
|
+
def get_event_contacts(self) -> list[dict]:
|
|
83
|
+
# profile_id -> contact_id (first mapping wins if a profile maps to more
|
|
84
|
+
# than one contact; the calendar view treats a registered profile as one
|
|
85
|
+
# person).
|
|
86
|
+
contact_id_by_profile_id: dict = {}
|
|
87
|
+
for row in self._reader.fetch_contact_profiles():
|
|
88
|
+
profile_id = row.get("profile_id")
|
|
89
|
+
contact_id = row.get("contact_id")
|
|
90
|
+
if profile_id is not None and contact_id is not None:
|
|
91
|
+
contact_id_by_profile_id.setdefault(profile_id, contact_id)
|
|
92
|
+
|
|
93
|
+
links = []
|
|
94
|
+
seen = set()
|
|
95
|
+
for event_profile in self._reader.fetch_event_profiles():
|
|
96
|
+
event_id = event_profile.get("event_id")
|
|
97
|
+
profile_id = event_profile.get("to_registered_profile_id")
|
|
98
|
+
contact_id = contact_id_by_profile_id.get(profile_id)
|
|
99
|
+
if contact_id is None:
|
|
100
|
+
# Registered profile with no linked contact -> not an attendee we
|
|
101
|
+
# can name; skip it.
|
|
102
|
+
continue
|
|
103
|
+
key = (event_id, contact_id)
|
|
104
|
+
if key in seen:
|
|
105
|
+
continue
|
|
106
|
+
seen.add(key)
|
|
107
|
+
links.append({
|
|
108
|
+
"event_id": event_id,
|
|
109
|
+
"contact_id": contact_id,
|
|
110
|
+
"role": _role_from_event_profile(event_profile),
|
|
111
|
+
})
|
|
112
|
+
return links
|
|
113
|
+
|
|
114
|
+
def get_group_memberships(self) -> list[dict]:
|
|
115
|
+
return [
|
|
116
|
+
{"contact_id": row["contact_id"], "group_name": row["group_name"]}
|
|
117
|
+
for row in self._reader.fetch_contact_group_memberships()
|
|
118
|
+
]
|