-
Notifications
You must be signed in to change notification settings - Fork 48
Expand file tree
/
Copy pathlead_store.py
More file actions
364 lines (294 loc) · 13.1 KB
/
Copy pathlead_store.py
File metadata and controls
364 lines (294 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
"""Lead source adapter — Notion or local JSON, picked at boot.
Why this exists
---------------
Hackathon participants who haven't wired Notion yet still need a working
canvas to extend. Without a fallback, an unconfigured kit boots into an
empty pipeline that looks broken. The local store solves that: on first
read it materializes a seed JSON of 50 real-shape leads (sourced from
the v2a Notion export) into `agent/data/leads.local.json`, and from then
on every read/write/delete is a plain file mutation. Edits persist
between sessions, and a `Reset local data` UI button can wipe the file
to get back to the seed.
Resolution rule (boot-time)
---------------------------
- Both NOTION_TOKEN and NOTION_LEADS_DATABASE_ID set → `NotionStore`.
- Otherwise → `LocalJsonStore` (auto-bootstraps from `leads.seed.json`).
The agent's tool surface in `notion_tools.py` calls `get_store()` and
treats the result generically — it does NOT branch on Notion vs Local.
This keeps the agent prompt mostly Notion-flavored (the existing tool
names were too entangled to rename without churn) while the actual
data path is data-source agnostic.
To rotate data without restarting the agent, call `reset_store()` —
the next `get_store()` call re-resolves and picks up any env / file
changes. The Next.js `/api/leads/reset` route relies on this when it
deletes `leads.local.json`.
"""
from __future__ import annotations
import json
import os
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional, Protocol
# Module locations. `agent/data/` ships the bundled seed; `leads.local.json`
# is gitignored and gets created on first read.
_AGENT_ROOT = Path(__file__).resolve().parent.parent
_DATA_DIR = _AGENT_ROOT / "data"
SEED_PATH = _DATA_DIR / "leads.seed.json"
LOCAL_PATH = _DATA_DIR / "leads.local.json"
class LeadStore(Protocol):
"""Common contract for backends that the agent's tools call into."""
def list_leads(self) -> list[dict[str, Any]]:
"""Return all leads. Empty list on failure (never None)."""
def update_lead(
self, lead_id: str, patch: dict[str, Any]
) -> Optional[dict[str, Any]]:
"""Apply `patch` to the lead with `lead_id`. Returns the merged row, or None on failure."""
def insert_lead(self, lead: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Create a new lead. Returns the new row (with id/url filled), or None on failure."""
def add_comment(
self, lead_id: str, body: str
) -> Optional[dict[str, Any]]:
"""Post a comment on the lead. Returns a `{ok: True, ...}` dict on success, None on failure."""
def database_title(self) -> str:
"""Human label for the source — surfaces in the canvas's sync banner."""
def is_local(self) -> bool:
"""True for the local store, False for any remote source."""
# --------------------------------------------------------------------- Notion
class NotionStore:
"""Thin facade over `notion_integration` so tools don't import it directly."""
def __init__(self, database_id: str) -> None:
self.database_id = database_id
def list_leads(self) -> list[dict[str, Any]]:
from .notion_integration import fetch_leads
rows = fetch_leads(self.database_id)
return rows or []
def update_lead(
self, lead_id: str, patch: dict[str, Any]
) -> Optional[dict[str, Any]]:
from .notion_integration import update_lead
return update_lead(self.database_id, lead_id, patch)
def insert_lead(self, lead: dict[str, Any]) -> Optional[dict[str, Any]]:
from .notion_integration import insert_lead
return insert_lead(self.database_id, lead)
def add_comment(
self, lead_id: str, body: str
) -> Optional[dict[str, Any]]:
from .notion_integration import add_lead_comment
result = add_lead_comment(lead_id, body)
if result is None:
return None
return {"ok": True, "comment_id": result.get("id", ""), "source": "notion"}
def database_title(self) -> str:
# Tries the live API first (handles renames cleanly), falls back to
# the well-known v2a name so the banner reads cleanly even if the
# round-trip is skipped or fails.
try:
from .notion_integration import get_database_schema # noqa: F401
from .notion_mcp import mcp_fetch_database_schema
db = mcp_fetch_database_schema(self.database_id)
title_parts = (db.get("title") if isinstance(db, dict) else None) or []
title = "".join(
p.get("plain_text", "") for p in title_parts if isinstance(p, dict)
)
return title or "AI Workshop Provider Community"
except Exception: # noqa: BLE001 — banner string is best-effort
return "AI Workshop Provider Community"
def is_local(self) -> bool:
return False
# ---------------------------------------------------------------------- local
# A single process-wide lock guards every read/write to leads.local.json
# so two concurrent tool calls (e.g. an in-flight `update_notion_lead`
# overlapping with the agent's next `fetch_notion_leads`) can't tear the
# file. The local-store path is a dev convenience, not a hot path, so a
# coarse lock is fine.
_FILE_LOCK = threading.Lock()
class LocalJsonStore:
"""File-backed store for the no-Notion-yet path.
On first `list_leads()`, copies `leads.seed.json` to `leads.local.json`.
From then on, the local file is the only source of truth. A reset is
just `os.unlink(local_path)` — the next read repopulates from the seed.
"""
def __init__(self, local_path: Path = LOCAL_PATH, seed_path: Path = SEED_PATH) -> None:
self.local_path = local_path
self.seed_path = seed_path
# -- helpers ---------------------------------------------------------
def _ensure_local(self) -> None:
"""Materialize the local file from the seed if it doesn't exist yet."""
if self.local_path.exists():
return
if not self.seed_path.exists():
# No seed shipped — create an empty list so list_leads() returns []
# rather than raising. Useful in tests where the seed is stubbed.
self.local_path.parent.mkdir(parents=True, exist_ok=True)
self.local_path.write_text("[]\n", encoding="utf-8")
return
seed = self.seed_path.read_text(encoding="utf-8")
self.local_path.parent.mkdir(parents=True, exist_ok=True)
self.local_path.write_text(seed, encoding="utf-8")
def _read_all(self) -> list[dict[str, Any]]:
self._ensure_local()
try:
return json.loads(self.local_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
print(
f"[lead_store] WARN: could not read {self.local_path}: {e}. "
"Returning empty list — fix or delete the file to recover.",
flush=True,
)
return []
def _write_all(self, rows: list[dict[str, Any]]) -> None:
# Atomic write: stage to a tmp sibling then rename, so a crash
# mid-write can't leave the local file in a half-flushed state.
tmp = self.local_path.with_suffix(".tmp")
tmp.write_text(
json.dumps(rows, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
os.replace(tmp, self.local_path)
# -- LeadStore impl --------------------------------------------------
def list_leads(self) -> list[dict[str, Any]]:
with _FILE_LOCK:
return self._read_all()
def update_lead(
self, lead_id: str, patch: dict[str, Any]
) -> Optional[dict[str, Any]]:
if not lead_id:
return None
with _FILE_LOCK:
rows = self._read_all()
for i, row in enumerate(rows):
if row.get("id") == lead_id:
merged = {**row, **(patch or {}), "id": lead_id}
rows[i] = merged
self._write_all(rows)
return merged
# No row matched — agent is referencing a stale id. Surface as None
# so the caller's error path (already wired) reports "update failed".
return None
def insert_lead(self, lead: dict[str, Any]) -> Optional[dict[str, Any]]:
new_id = str(uuid.uuid4())
new_row = {
"id": new_id,
"url": None,
"submitted_at": datetime.now(timezone.utc).isoformat(),
# Sensible defaults for the canvas fields; the agent's payload
# overrides anything it explicitly sets.
"name": "",
"company": "",
"email": "",
"role": "",
"phone": "",
"source": "",
"technical_level": "",
"interested_in": [],
"tools": [],
"workshop": "Not sure yet",
"status": "Not started",
"opt_in": False,
"message": "",
**(lead or {}),
# Force id/url last so the agent can't override them.
"id": new_id,
"url": None,
}
with _FILE_LOCK:
rows = self._read_all()
rows.append(new_row)
self._write_all(rows)
return new_row
def add_comment(
self, lead_id: str, body: str
) -> Optional[dict[str, Any]]:
"""Append a comment to the lead row in the local JSON file.
The local store has no Notion to post to, so the "send" gesture
instead persists a comment record on the lead itself. This keeps
the demo flow working end-to-end without Notion configured.
"""
if not lead_id:
return None
comment = {
"id": str(uuid.uuid4()),
"body": body or "",
"created_at": datetime.now(timezone.utc).isoformat(),
}
with _FILE_LOCK:
rows = self._read_all()
for i, row in enumerate(rows):
if row.get("id") == lead_id:
existing = list(row.get("comments") or [])
existing.append(comment)
rows[i] = {**row, "comments": existing}
self._write_all(rows)
return {
"ok": True,
"comment_id": comment["id"],
"source": "local",
}
return None
def database_title(self) -> str:
return "Local: starter data"
def is_local(self) -> bool:
return True
# --------------------------------------------------------------- factory
_store_singleton: LeadStore | None = None
_resolve_lock = threading.Lock()
def get_store() -> LeadStore:
"""Return the resolved store, building it on first call.
Tools call this on every invocation so a `reset_store()` between
calls is picked up immediately. The lock ensures we don't double-
construct under concurrent first-touch.
"""
global _store_singleton
if _store_singleton is not None:
return _store_singleton
with _resolve_lock:
if _store_singleton is None:
_store_singleton = _resolve()
return _store_singleton
def reset_store() -> None:
"""Force the next `get_store()` call to re-resolve.
Used by the reset path: the Next.js route deletes `leads.local.json`
and would otherwise have no way to invalidate any cached state. We
don't actually cache rows in `LocalJsonStore` (every read hits the
file), but a future `NotionStore` cache could, so this hook stays.
"""
global _store_singleton
with _resolve_lock:
_store_singleton = None
def _resolve() -> LeadStore:
"""Pick the store based on env. See module docstring for the rule."""
notion_token = os.getenv("NOTION_TOKEN", "").strip()
notion_db = os.getenv("NOTION_LEADS_DATABASE_ID", "").strip()
if notion_token and notion_db:
return NotionStore(notion_db)
return LocalJsonStore()
# ----------------------------------------------------------------- diagnostics
def boot_status() -> str:
"""Format a one-line boot log for `agent/main.py` to print.
Covers both store types so the operator can see which path is active
without scrolling through a multi-line health block.
"""
store = get_store()
if store.is_local():
local_path = LOCAL_PATH
rows = store.list_leads()
return (
f'ok: source=local path="{local_path.relative_to(_AGENT_ROOT)}" '
f"rows={len(rows)} (set NOTION_TOKEN + NOTION_LEADS_DATABASE_ID to "
"switch to Notion)"
)
# Notion path — defer to the existing health check so any setup
# gotchas (token, share, schema drift) show up in the boot log.
from .notion_integration import health_check
health = health_check()
db_title = health.get("db_title") or "<unknown>"
rows = health.get("row_count", 0)
missing = health.get("missing_props") or []
error = health.get("error")
if error:
return f'error: {error} (db="{db_title}", rows={rows}, missing={missing})'
return (
f'ok: source=notion db="{db_title}" rows={rows} missing={missing}'
)