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.
Files changed (47) hide show
  1. compass_local-0.0.1/PKG-INFO +27 -0
  2. compass_local-0.0.1/README.md +56 -0
  3. compass_local-0.0.1/compass_local/__init__.py +0 -0
  4. compass_local-0.0.1/compass_local/src/__init__.py +0 -0
  5. compass_local-0.0.1/compass_local/src/calendaring/__init__.py +5 -0
  6. compass_local-0.0.1/compass_local/src/calendaring/calendar_agent.py +97 -0
  7. compass_local-0.0.1/compass_local/src/calendaring/calendar_data_source.py +39 -0
  8. compass_local-0.0.1/compass_local/src/calendaring/calendar_reader.py +37 -0
  9. compass_local-0.0.1/compass_local/src/calendaring/calendar_seed_data.py +141 -0
  10. compass_local-0.0.1/compass_local/src/calendaring/calendar_tools.py +126 -0
  11. compass_local-0.0.1/compass_local/src/calendaring/event_profile_data_source.py +118 -0
  12. compass_local-0.0.1/compass_local/src/calendaring/event_profiles_local.py +125 -0
  13. compass_local-0.0.1/compass_local/src/calendaring/fixture_data_source.py +55 -0
  14. compass_local-0.0.1/compass_local/src/calendaring/generic_crud_calendar_reader.py +156 -0
  15. compass_local-0.0.1/compass_local/src/constants.py +35 -0
  16. compass_local-0.0.1/compass_local/src/contacts/__init__.py +8 -0
  17. compass_local-0.0.1/compass_local/src/contacts/agent.py +104 -0
  18. compass_local-0.0.1/compass_local/src/contacts/data_source.py +71 -0
  19. compass_local-0.0.1/compass_local/src/contacts/seed_data.py +92 -0
  20. compass_local-0.0.1/compass_local/src/contacts/tools.py +127 -0
  21. compass_local-0.0.1/compass_local/src/core/__init__.py +7 -0
  22. compass_local-0.0.1/compass_local/src/core/agent.py +27 -0
  23. compass_local-0.0.1/compass_local/src/core/agent_response.py +32 -0
  24. compass_local-0.0.1/compass_local/src/core/agent_return_code.py +17 -0
  25. compass_local-0.0.1/compass_local/src/core/llm_client.py +15 -0
  26. compass_local-0.0.1/compass_local/src/core/routing_agent.py +113 -0
  27. compass_local-0.0.1/compass_local/src/version.py +1 -0
  28. compass_local-0.0.1/compass_local/tests/__init__.py +0 -0
  29. compass_local-0.0.1/compass_local/tests/calendar_agent_test.py +465 -0
  30. compass_local-0.0.1/compass_local/tests/calendar_tools_test.py +152 -0
  31. compass_local-0.0.1/compass_local/tests/conftest.py +18 -0
  32. compass_local-0.0.1/compass_local/tests/contacts_agent_test.py +224 -0
  33. compass_local-0.0.1/compass_local/tests/contacts_data_source_test.py +38 -0
  34. compass_local-0.0.1/compass_local/tests/contacts_tools_test.py +240 -0
  35. compass_local-0.0.1/compass_local/tests/event_profile_data_source_test.py +187 -0
  36. compass_local-0.0.1/compass_local/tests/event_profiles_local_test.py +18 -0
  37. compass_local-0.0.1/compass_local/tests/fixture_data_source_test.py +38 -0
  38. compass_local-0.0.1/compass_local/tests/generic_crud_calendar_reader_test.py +235 -0
  39. compass_local-0.0.1/compass_local/tests/smoke_test.py +12 -0
  40. compass_local-0.0.1/compass_local.egg-info/PKG-INFO +27 -0
  41. compass_local-0.0.1/compass_local.egg-info/SOURCES.txt +45 -0
  42. compass_local-0.0.1/compass_local.egg-info/dependency_links.txt +1 -0
  43. compass_local-0.0.1/compass_local.egg-info/requires.txt +5 -0
  44. compass_local-0.0.1/compass_local.egg-info/top_level.txt +1 -0
  45. compass_local-0.0.1/pyproject.toml +30 -0
  46. compass_local-0.0.1/setup.cfg +4 -0
  47. 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,5 @@
1
+ """The Calendar agent: deterministic calendar tools + its reasoning layer.
2
+
3
+ Named ``calendaring`` (not ``calendar``) so it does not shadow the Python
4
+ standard library's ``calendar`` module on the test/runtime path.
5
+ """
@@ -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
+ ]