diff --git a/.env.example b/.env.example index 854c146..79b970f 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,8 @@ MAKE_MCP_TOKEN= GCP_MCP_URL= DIA_AGENT_MCP_MODE=auto DIA_AGENT_ALLOW_GCP_MCP_TOOLS= +# Client memory Sheet (optional): the ID of a Google Sheet with `clients` and +# `engagements` tabs — DIA recalls repeat clients and records every scored +# intake there. Auth is ADC (spreadsheets scope); share the Sheet with the +# service-account email when deployed. Leave unset to run memoryless. +MEMORY_SHEET_ID= diff --git a/README.md b/README.md index ed4ee89..378c67b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,33 @@ The MCP URL is configuration, not a secret. Authentication uses the Cloud Run runtime service account through Application Default Credentials, so grant that service account only the Google Cloud roles the selected MCP endpoint needs. +## Client memory (optional, Sheets-backed) + +DIA can remember clients between intakes using a Google Sheet **in your own +Google account** — client data never leaves your Drive and never touches any +shared infrastructure. Design: `docs/2026-07-07_client-memory-layer_design.md`. + +Setup: + +1. Create a Google Sheet with two tabs named `clients` and `engagements`. + Put these header rows in row 1: + - `clients`: `client_id | company_name | website | industry | first_seen_at | last_seen_at | engagement_count | last_tier | last_total_score | status | updated_at` + - `engagements`: `engagement_id | client_id | timestamp | source | tier | total_score | max_score | pain_points | goals | budget | documents_generated | proposal_outcome | notes` +2. Set `MEMORY_SHEET_ID` to the Sheet's ID (the long segment of its URL). +3. Auth is ADC: locally, `gcloud auth application-default login`; on Cloud + Run, share the Sheet (Editor) with the runtime service account's email. + +Behavior: every scored intake appends an `engagements` row (INSERT_ROWS — +safe under concurrent writers) and updates the client's rollup row. The live +agent gains a `recall_client_history` tool and references prior engagements +when a repeat company comes in. `proposal_outcome` and `status` are yours to +edit directly in the Sheet (e.g. mark a proposal `won`) — human-in-the-loop +memory editing is a feature of the design, not a workaround. + +Unset `MEMORY_SHEET_ID` and DIA behaves exactly as before — memory is an +enhancement, never a dependency; an unreachable Sheet degrades to a logged +warning and never blocks or delays an intake. + ## Run in Codespaces This repo includes a `.devcontainer/devcontainer.json` for GitHub Codespaces diff --git a/app.py b/app.py index b700f73..f927132 100644 --- a/app.py +++ b/app.py @@ -116,6 +116,11 @@ def handle_process(self) -> None: self.send_json(response) + # Record the completed run in client memory AFTER the response is on + # the wire, so a slow or unreachable Sheet can never delay an intake. + # No-op unless MEMORY_SHEET_ID is set; never raises. + self._record_engagement(response) + # OBS capture is a LOCAL-ONLY convenience for recording demo videos. # It is off by default (so the deployed Cloud Run service never pokes a # non-existent OBS) and only runs when OBS_CAPTURE is explicitly enabled @@ -123,6 +128,22 @@ def handle_process(self) -> None: if response.get("issues") and _obs_capture_enabled(): self._trigger_obs_capture() + def _record_engagement(self, response: dict[str, object]) -> None: + try: + from client_discovery import memory + from client_discovery.models import ClientIntake, OpportunityScore + + if not memory.is_enabled(): + return + memory.record_engagement( + ClientIntake(**response["intake"]), + OpportunityScore(**response["score"]), + source="/api/process", + documents=sorted(response.get("documents", {})), + ) + except Exception as error: # noqa: BLE001 - memory must never break intake + logger.warning("client memory recording skipped: %s", error) + def _trigger_obs_capture(self) -> None: try: from client_discovery.core import ( diff --git a/character.py b/character.py index c80bee1..17239a7 100644 --- a/character.py +++ b/character.py @@ -12,6 +12,7 @@ score_opportunity, validate_intake, ) +from client_discovery import memory REPO_ROOT = Path(__file__).resolve().parent @@ -45,7 +46,20 @@ def generate_intake_documents(questionnaire_markdown: str) -> dict: """Generate profile, opportunity analysis, and proposal draft markdown.""" intake = parse_questionnaire_markdown(questionnaire_markdown) score = score_opportunity(intake) - return generate_documents(intake, score) + documents = generate_documents(intake, score) + # Record the completed run in client memory (no-op unless MEMORY_SHEET_ID + # is set; never raises). This is the agent path's write trigger — the + # deterministic /api/process path records in app.py. + memory.record_engagement( + intake, score, source="/api/agent", documents=sorted(documents) + ) + return documents + + +def recall_client_history(company_name: str, website: str = "") -> dict: + """Look up whether this company has been seen before and return its + prior engagement history (client record + recent intake runs).""" + return memory.recall_summary(company_name, website) def _make_mcp_toolset() -> list: @@ -142,6 +156,12 @@ def build_agent( score_client_opportunity, generate_intake_documents, ] + # Plain-function tool, not MCP (spec Open Question #6): auto-included when + # MEMORY_SHEET_ID is set, same present-means-on pattern as the MCP URLs. + # Cheap to include unconditionally per turn — it's a local function, not a + # network toolset, so it needs no per-message keyword routing. + if memory.is_enabled(): + tools.append(recall_client_history) if include_make_mcp: tools.extend(_make_mcp_toolset()) if include_gcp_mcp: @@ -172,6 +192,10 @@ def build_agent( - When GCP Cloud tools are available (monitoring, logging, etc.), use them for operational questions about the agent itself, e.g. its own request metrics or recent errors. Report exactly which tool ran and what it returned. + - When the recall_client_history tool is available and an intake names a + company, check for prior history before drafting recommendations. If the + client is known, reference the prior engagement context (last tier, prior + pain points, proposal status) instead of treating them as brand new. """, generate_content_config=types.GenerateContentConfig( http_options=types.HttpOptions( diff --git a/client_discovery/memory.py b/client_discovery/memory.py new file mode 100644 index 0000000..b58ca5f --- /dev/null +++ b/client_discovery/memory.py @@ -0,0 +1,330 @@ +"""Sheets-backed client memory for DIA. + +Implements docs/2026-07-07_client-memory-layer_design.md: a Google Sheet in +the *operator's own* Google account acts as DIA's client memory. Two tabs: + + clients one row per known company (identity + rollup) + engagements one row per intake run (append-only ledger) + +Design constraints carried over from the sheets-mini-db proof +(agent-eggs/sheets-mini-db/PROOF.md): + +- Writes use ``insertDataOption=INSERT_ROWS`` only. Naive append (OVERWRITE) + silently loses concurrent writes (9/12 lost in the 12-writer test); + INSERT_ROWS survived 12/12. +- The engagements row is written FIRST, the clients rollup second. Sheets has + no cross-row transactions, so if the rollup update fails the durable + ledger is still intact and the rollup is recomputable — nothing is lost, + only briefly stale. +- Memory is an enhancement, never a dependency: MEMORY_SHEET_ID unset means + every function is a silent no-op, and any API/auth failure degrades to a + logged warning — the same posture app.py takes for a missing Gemini key. + +Client data never touches the HF skill registry or any shared +infrastructure; it lives only in the operator's Sheet. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import uuid +from dataclasses import asdict +from datetime import datetime, timezone +from urllib.parse import quote, urlparse + +from client_discovery.models import ClientIntake, OpportunityScore + +logger = logging.getLogger(__name__) + +SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets" +SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets" +# Sheets reads run ~0.6-1.4s per the proof; a stuck call must fall back to +# "no history" rather than pin an intake request. +REQUEST_TIMEOUT_SECONDS = 8 +# Cap recalled engagements to bound agent context size and read volume. +HISTORY_LIMIT = 3 + +CLIENTS_TAB = "clients" +ENGAGEMENTS_TAB = "engagements" + +CLIENTS_HEADERS = [ + "client_id", + "company_name", + "website", + "industry", + "first_seen_at", + "last_seen_at", + "engagement_count", + "last_tier", + "last_total_score", + "status", + "updated_at", +] + +ENGAGEMENTS_HEADERS = [ + "engagement_id", + "client_id", + "timestamp", + "source", + "tier", + "total_score", + "max_score", + "pain_points", + "goals", + "budget", + "documents_generated", + "proposal_outcome", + "notes", +] + + +def memory_sheet_id() -> str: + return os.environ.get("MEMORY_SHEET_ID", "").strip() + + +def is_enabled() -> bool: + """Memory is on only when MEMORY_SHEET_ID is set, mirroring MAKE_MCP_URL.""" + return bool(memory_sheet_id()) + + +def client_id_for(company_name: str, website: str = "") -> str: + """Derive a stable client id from normalized name + website domain. + + Composite key per the spec's Open Question #1 resolution: simplest + approach, no new dependency. Ambiguous matches surface as duplicate rows + the operator can merge in the Sheet — human-in-the-loop cleanup is a + designed-in property of the store, not a failure. + """ + name_part = re.sub(r"[^a-z0-9]+", "-", company_name.strip().lower()).strip("-") + domain = urlparse(website if "//" in website else f"//{website}").netloc + domain_part = domain.lower().removeprefix("www.") + return f"{name_part}|{domain_part}" if domain_part else name_part + + +def _access_token() -> str | None: + """Fetch an ADC bearer token, same pattern as character._gcp_mcp_toolset.""" + try: + import google.auth + import google.auth.transport.requests + except ImportError as error: + logger.warning("client memory disabled, google-auth missing: %s", error) + return None + try: + credentials, _ = google.auth.default(scopes=[SHEETS_SCOPE]) + credentials.refresh(google.auth.transport.requests.Request()) + except Exception as error: # noqa: BLE001 - memory must never break intake + logger.warning("client memory disabled, ADC unavailable: %s", error) + return None + return credentials.token + + +def _sheet_request(method: str, url: str, token: str, payload: dict | None = None) -> dict: + import requests + + response = requests.request( + method, + url, + headers={"Authorization": f"Bearer {token}"}, + json=payload, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + return response.json() if response.content else {} + + +def _read_tab(sheet_id: str, token: str, tab: str) -> list[list[str]]: + url = f"{SHEETS_API}/{sheet_id}/values/{quote(tab)}" + return _sheet_request("GET", url, token).get("values", []) + + +def _rows_as_dicts(rows: list[list[str]], headers: list[str]) -> list[dict[str, str]]: + """Map sheet rows to dicts by the tab's actual header row. + + The first row of the tab is trusted as the header so operator-added + columns (or reordered ones) don't corrupt field mapping. + """ + if not rows: + return [] + actual_headers = [h.strip() for h in rows[0]] or headers + out = [] + for row in rows[1:]: + padded = row + [""] * (len(actual_headers) - len(row)) + out.append(dict(zip(actual_headers, padded))) + return out + + +def _append_row(sheet_id: str, token: str, tab: str, row: list[object]) -> None: + """Append one row with INSERT_ROWS — the only write primitive proven safe + under concurrency (12/12 survive vs 3/12 for naive OVERWRITE append).""" + url = ( + f"{SHEETS_API}/{sheet_id}/values/{quote(tab)}:append" + "?valueInputOption=RAW&insertDataOption=INSERT_ROWS" + ) + _sheet_request("POST", url, token, {"values": [row]}) + + +def _update_row(sheet_id: str, token: str, tab: str, row_number: int, row: list[object]) -> None: + rng = quote(f"{tab}!A{row_number}") + url = f"{SHEETS_API}/{sheet_id}/values/{rng}?valueInputOption=RAW" + _sheet_request("PUT", url, token, {"values": [row]}) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def read_client_history(company_name: str, website: str = "") -> dict | None: + """Return the matched clients row + last N engagements, or None. + + None means "no history available" for any reason — unset config, no + match, or an unreachable Sheet (logged, never raised). + """ + sheet_id = memory_sheet_id() + if not sheet_id or not company_name.strip(): + return None + token = _access_token() + if token is None: + return None + try: + target = client_id_for(company_name, website) + name_only = client_id_for(company_name) + clients = _rows_as_dicts( + _read_tab(sheet_id, token, CLIENTS_TAB), CLIENTS_HEADERS + ) + # Exact composite match first; fall back to name-only so an intake + # missing the website still recalls the client. + client = next((c for c in clients if c.get("client_id") == target), None) + if client is None: + client = next( + ( + c + for c in clients + if c.get("client_id", "").split("|")[0] == name_only + ), + None, + ) + if client is None: + return None + engagements = _rows_as_dicts( + _read_tab(sheet_id, token, ENGAGEMENTS_TAB), ENGAGEMENTS_HEADERS + ) + history = [ + e for e in engagements if e.get("client_id") == client.get("client_id") + ] + return {"client": client, "engagements": history[-HISTORY_LIMIT:]} + except Exception as error: # noqa: BLE001 - memory must never break intake + logger.warning("client memory read failed, proceeding without history: %s", error) + return None + + +def record_engagement( + intake: ClientIntake, score: OpportunityScore, source: str, documents: list[str] | None = None +) -> None: + """Persist one intake run: engagements ledger row first, rollup second. + + Never raises. A failure after the ledger write leaves the rollup stale + but recomputable — the deliberate ordering choice from the spec. + """ + sheet_id = memory_sheet_id() + if not sheet_id or not intake.company_name.strip(): + return + token = _access_token() + if token is None: + return + cid = client_id_for(intake.company_name, intake.website) + now = _now() + try: + _append_row( + sheet_id, + token, + ENGAGEMENTS_TAB, + [ + uuid.uuid4().hex[:12], + cid, + now, + source, + score.tier, + score.total_score, + score.max_score, + json.dumps(intake.pain_points), + json.dumps(intake.goals), + intake.budget, + json.dumps(documents or []), + "pending", # outcome is set later by the operator in the Sheet + "", + ], + ) + except Exception as error: # noqa: BLE001 - memory must never break intake + logger.warning("client memory write failed, engagement not recorded: %s", error) + return + try: + _upsert_client_rollup(sheet_id, token, cid, intake, score, now) + except Exception as error: # noqa: BLE001 - ledger row already durable + logger.warning("client rollup update failed (ledger row intact): %s", error) + + +def _upsert_client_rollup( + sheet_id: str, + token: str, + cid: str, + intake: ClientIntake, + score: OpportunityScore, + now: str, +) -> None: + rows = _read_tab(sheet_id, token, CLIENTS_TAB) + existing = _rows_as_dicts(rows, CLIENTS_HEADERS) + for index, client in enumerate(existing): + if client.get("client_id") == cid: + count = int(client.get("engagement_count") or 0) + 1 + _update_row( + sheet_id, + token, + CLIENTS_TAB, + index + 2, # +1 header row, +1 one-based + [ + cid, + intake.company_name, + intake.website, + intake.industry, + client.get("first_seen_at") or now, + now, + count, + score.tier, + score.total_score, + client.get("status") or "prospect", + now, + ], + ) + return + _append_row( + sheet_id, + token, + CLIENTS_TAB, + [ + cid, + intake.company_name, + intake.website, + intake.industry, + now, + now, + 1, + score.tier, + score.total_score, + "prospect", + now, + ], + ) + + +def recall_summary(company_name: str, website: str = "") -> dict: + """Agent-tool-friendly wrapper: always returns a JSON-safe dict.""" + history = read_client_history(company_name, website) + if history is None: + return { + "known_client": False, + "message": "No prior history for this company (or memory is not configured).", + } + return {"known_client": True, **history} diff --git a/docs/2026-07-07_client-memory-layer_design.md b/docs/2026-07-07_client-memory-layer_design.md new file mode 100644 index 0000000..3286ca2 --- /dev/null +++ b/docs/2026-07-07_client-memory-layer_design.md @@ -0,0 +1,207 @@ +# Design: Client Memory Layer (Sheets-Backed) + +## Date: 2026-07-07 + +## Context + +DIA currently treats every intake as stateless: one questionnaire in, three +documents out, no memory of whether this company has been seen before, what +was discussed last time, or whether a prior proposal was sent, won, or lost. + +This session proved out a reusable pattern for exactly this kind of state — +`sheets-mini-db` (see `agent-eggs/sheets-mini-db/PROOF.md`): a Google Sheet +used as a live, multi-writer datastore. Proven live: naive `append` +(OVERWRITE mode) silently loses concurrent writes (3/12 survived in a +12-parallel-writer test); `append --insert INSERT_ROWS`, or routing through +an Apps Script `LockService` web app, is safe (12/12, zero loss). + +This spec defines a client-memory layer for DIA built on that pattern. It is +a **specification only** — no implementation. Building it is deliberately +deferred to a later session. + +## Non-goals (explicitly out of scope here) + +- Any code changes. +- The gog action loop (Gmail/Drive/Sheets automation replacing the Make + scenarios) — separate spec. +- Stripe / revenue tracking — separate spec. +- Splitting DIA into multiple agents (research agent, proposal agent) — + not required for memory and out of scope. +- NotebookLM-grade RAG grounding over uploaded client documents — later, + separate, and a different mechanism (Vertex AI RAG / Gemini File Search) + from the structured memory described here. + +## Design principles + +- **Client data never touches the HF skill registry.** That registry + (`45Navy/agent-skills`) is for portable *capabilities* (skills, code, + memory-pods about the *project itself*). A client's pain points, budget, + and proposal history are business data and belong in the operator's own + Google Sheet/Drive — never mixed into shared infrastructure other agents + or sessions read from. This is the same boundary rule this session used + for the `gog` skill registry vs. the sheets-mini-db proof. +- **Single-tenant for v1.** One Sheet per DIA deployment (i.e., per agency + running it), not a shared multi-client-of-DIA sheet. Multi-tenant (each + reseller customer with their own Sheet/OAuth) is a bigger redesign and + explicitly deferred. +- **Degrades gracefully, same posture as the Gemini key.** `app.py` already + runs the deterministic pipeline with no API key and only enables the live + agent when one is configured, logging a warning otherwise. Memory must + follow the identical shape: unset → DIA behaves exactly as it does today, + zero errors, zero behavior change. It is an enhancement, never a hard + dependency. +- **Writes use the proven-safe primitive.** `INSERT_ROWS` (or the Apps + Script `LockService` web app) only. Naive `append` is disqualified by the + proof — it silently drops data under concurrency. + +## Data model + +Two tables, both a persistence view of dataclasses that already exist in +`client_discovery/models.py` — no new fields are invented, only persisted. + +**`clients`** — one row per known company, identity + rollup: + +| column | source | +|---|---| +| `client_id` | derived (see Open Questions #1) | +| `company_name` | `ClientIntake.company_name` | +| `website` | `ClientIntake.website` | +| `industry` | `ClientIntake.industry` | +| `first_seen_at` | set on first write | +| `last_seen_at` | updated every write | +| `engagement_count` | incremented every write | +| `last_tier` | `OpportunityScore.tier` | +| `last_total_score` | `OpportunityScore.total_score` | +| `status` | prospect / proposal_sent / won / lost / dormant | +| `updated_at` | every write | + +**`engagements`** — one row per intake run, append-only ledger: + +| column | source | +|---|---| +| `engagement_id` | derived | +| `client_id` | FK to `clients` row | +| `timestamp` | write time | +| `source` | which endpoint ran it: `/api/process` or `/api/agent` | +| `tier`, `total_score`, `max_score` | `OpportunityScore` | +| `pain_points`, `goals` | `ClientIntake` (serialized) | +| `budget` | `ClientIntake.budget` | +| `documents_generated` | which of the 3 docs were produced | +| `proposal_outcome` | pending / sent / won / lost / n-a — **not** set at write time (see Write path) | +| `notes` | free text | + +## Read path — when DIA recalls memory + +- **Trigger:** a new intake's `company_name` (normalized) matches an + existing `clients` row. +- **What's pulled:** the matched `clients` row + the last N `engagements` + rows (cap at ~3 to bound context size and read volume). +- **Where it plugs in:** + - Deterministic path (`client_discovery.core.generate_documents`) — + future enhancement to let the proposal draft reference prior + engagement context. Not part of this spec's minimum scope. + - Agent path (`character.py`) — a new tool, e.g. + `recall_client_history(company_name)`, added to the `tools` list built + in `build_agent()`. This follows the **exact existing pattern** of + `_make_mcp_toolset()` / `_gcp_mcp_toolset()`: gated by an env var + (e.g. `MEMORY_SHEET_ID`), returns a no-op/absent tool when unset, so + keyless and local test runs are unaffected. The LLM decides when to + call it. +- **Latency budget:** Sheets reads run ~0.6–1.4s per the proof. Acceptable + for a per-intake read (not a hot path), but must time out and fall back + to "no history available" rather than block document generation. + +## Write path — when DIA records memory + +- **Trigger:** after a `/api/process` or `/api/agent` run completes + scoring successfully (see Open Questions #3 for the validation-failure + case). +- **What's written:** one new `engagements` row (INSERT_ROWS-safe append), + then an update to the matching (or newly created) `clients` row's + rollup fields. +- **Ordering matters, and is a deliberate design choice:** the two writes + are not atomic against each other — Sheets has no cross-row + transactions (an honest limit from the proof). Write the `engagements` + row **first** (durable, append-only, safe even if the next write fails), + then update the `clients` rollup. If the rollup update fails, history is + still intact and the rollup is recomputable later; nothing is lost, + only briefly stale. +- **`proposal_outcome` is intentionally not set at write time.** DIA has + no way to know at intake time whether a proposal will be sent, won, or + lost. This field is updated later — either by the agency owner editing + the cell directly in the Sheet, or by a future automation hook (e.g. a + `gog-gmail` reply-detection step, out of scope here). This is the + "human-in-the-loop memory editing" property that any Sheets-backed store + gets for free, and it should be treated as a designed-in feature, not a + gap to fix later. + +## Where this lives architecturally + +- **New module (future):** `client_discovery/memory.py`. `core.py` today + has zero external I/O — it's pure parsing/scoring/templating. Memory + read/write against the Sheets API is a distinct concern and should not + be folded into `core.py`; a separate module preserves that purity, the + same way `agent_runtime.py` is kept separate from `app.py`. +- **Config:** one new env var, `MEMORY_SHEET_ID`, following the exact + present-means-on / absent-means-off pattern already used by + `MAKE_MCP_URL`, `GCP_MCP_URL`, and `GEMINI_API_KEY`. +- **Agent tool surface:** `character.py` gains a memory toolset (plain + Python functions, e.g. `recall_client_history`, optionally + `record_engagement_outcome` for explicit "mark this proposal won" during + a chat), added in `build_agent()` alongside the existing + `include_make_mcp` / `include_gcp_mcp` flags — e.g. `include_memory`, + auto-detected from `MEMORY_SHEET_ID` presence. +- **HTTP surface (optional, not required for the core loop):** a + read-only endpoint such as `GET /api/client/{id}/history` so the UI can + show "we've seen this client before." + +## Failure modes / degradation + +| condition | behavior | +|---|---| +| `MEMORY_SHEET_ID` unset | identical to today's behavior, no errors | +| Sheet unreachable at read time | log a warning, proceed with no history — same posture as the GCP MCP toolset's `except Exception` → disabled-with-warning | +| Sheet unreachable at write time | see Open Questions #4 — not yet decided | +| concurrent writes from multiple intakes | `INSERT_ROWS` handles this per the proof (12/12 survive); no special handling needed | + +## What this buys for the XPRIZE pitch + +- **"AI-Native Operations"** — memory demonstrates the AI tracking actual + business state (client history, deal status), a concrete answer to + "does AI execute key decisions," not a stateless form-filler. +- **"Business Viability"** — the `engagements` ledger is a machine-countable + record of real client engagements run through DIA over the judging + window, not a claim. +- **Trust story for small agencies** — "your client history lives in a + spreadsheet in your own Drive, not our database" directly answers the + two standard objections to adopting an AI tool: data lock-in and vendor + disappearance. + +## Open questions (unresolved — decide before implementation) + +1. **Identity resolution.** How a new intake's `company_name` is matched + to an existing `clients` row. Candidates: normalized name + website + domain as a composite key (simplest, no new dependency); human-in-the- + loop confirmation on ambiguous matches; accept some early duplication + and clean up later. Not resolved here. +2. **Auth mechanism** for Sheets API access from the Cloud Run deployment + — service account vs. OAuth vs. reusing `gog`'s stored credentials. The + README's existing ADC pattern for GCP-managed MCP tools is a likely + reusable precedent, but Sheets API scope grants need confirming. +3. **Do failed/invalid intakes get an `engagements` row?** Probably not — + likely only engagements that reach a scored state should be recorded — + but this should be an explicit decision, not a default. +4. **Write reliability tier for v1.** Accept data loss on a Sheet outage + at write time (simpler, matches this repo's ship-then-iterate posture) + vs. a local retry queue (more robust, more complexity). Not resolved. +5. **Single-tenant vs. multi-tenant.** V1 assumes one Sheet per DIA + deployment. If DIA is ever resold to multiple agencies, each needs + their own Sheet and credentials — a materially bigger redesign, + explicitly out of scope for this spec. +6. **Plain tool vs. MCP for `recall_client_history`.** A plain Python tool + is simpler and adds no infrastructure dependency, matching + `parse_intake` et al. Routing through MCP would make sense only if a + shared "memory MCP server" is later built for reuse across other agents + — plausible given this session's broader direction, but not justified + for DIA alone. Recommend the plain-tool approach; flag MCP as the + future alternative. diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..25bd258 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,250 @@ +import sys +import os + +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, project_root) +venv_site_packages = os.path.join(project_root, ".venv", "Lib", "site-packages") +if os.path.exists(venv_site_packages): + sys.path.insert(0, venv_site_packages) + +import pytest + +from client_discovery import memory +from client_discovery.models import ClientIntake, OpportunityScore + + +SCORE = OpportunityScore( + tier="Tier 2", + scope="scope", + price_range="$10k", + timeline="6 weeks", + urgency=3, + budget_fit=3, + tech_readiness=2, + strategic_value=3, + total_score=11, + max_score=16, + reasons=["r"], +) + +INTAKE = ClientIntake( + company_name="Acme Corp", + website="https://www.acme.example", + industry="Manufacturing", + pain_points=["manual invoicing"], + goals=["automate ops"], + budget="$10k-$25k", +) + + +class SheetCapture: + """Stub for memory._sheet_request that records calls and serves reads.""" + + def __init__(self, tabs=None): + self.tabs = tabs or {} + self.calls = [] + self.fail_on = None + + def __call__(self, method, url, token, payload=None): + self.calls.append((method, url, payload)) + if self.fail_on and self.fail_on in url: + raise RuntimeError("sheet unreachable") + if method == "GET": + for tab, rows in self.tabs.items(): + if f"/values/{tab}" in url: + return {"values": rows} + return {} + return {} + + +@pytest.fixture +def sheet(monkeypatch): + capture = SheetCapture() + monkeypatch.setenv("MEMORY_SHEET_ID", "sheet123") + monkeypatch.setattr(memory, "_access_token", lambda: "tok") + monkeypatch.setattr(memory, "_sheet_request", capture) + return capture + + +# --- identity ----------------------------------------------------------- + + +def test_client_id_normalizes_name_and_domain(): + assert ( + memory.client_id_for("Acme Corp!", "https://www.Acme.Example/path") + == "acme-corp|acme.example" + ) + + +def test_client_id_without_website_is_name_only(): + assert memory.client_id_for(" Acme Corp ") == "acme-corp" + + +def test_client_id_bare_domain_without_scheme(): + assert memory.client_id_for("Acme", "www.acme.example") == "acme|acme.example" + + +# --- disabled = no-op ---------------------------------------------------- + + +def test_unset_sheet_id_disables_everything(monkeypatch): + monkeypatch.delenv("MEMORY_SHEET_ID", raising=False) + monkeypatch.setattr( + memory, "_access_token", lambda: pytest.fail("must not fetch a token") + ) + assert memory.is_enabled() is False + assert memory.read_client_history("Acme Corp") is None + memory.record_engagement(INTAKE, SCORE, source="/api/process") # no raise + + +def test_recall_summary_reports_unknown_when_disabled(monkeypatch): + monkeypatch.delenv("MEMORY_SHEET_ID", raising=False) + assert memory.recall_summary("Acme Corp")["known_client"] is False + + +# --- write path ---------------------------------------------------------- + + +def test_record_engagement_writes_ledger_first_with_insert_rows(sheet): + memory.record_engagement(INTAKE, SCORE, source="/api/process", documents=["a.md"]) + + appends = [c for c in sheet.calls if ":append" in c[1]] + assert len(appends) == 2 # engagements ledger + new client rollup + # Ordering: durable ledger row first, rollup second. + assert "engagements:append" in appends[0][1] + assert "clients:append" in appends[1][1] + # The proof-mandated primitive on every append. + assert all("insertDataOption=INSERT_ROWS" in c[1] for c in appends) + + ledger_row = appends[0][2]["values"][0] + assert ledger_row[1] == "acme-corp|acme.example" + assert ledger_row[3] == "/api/process" + assert ledger_row[11] == "pending" # outcome set later by the operator + + +def test_record_engagement_updates_existing_client_rollup(sheet): + sheet.tabs = { + "clients": [ + memory.CLIENTS_HEADERS, + [ + "acme-corp|acme.example", + "Acme Corp", + "https://acme.example", + "Manufacturing", + "2026-07-01T00:00:00+00:00", + "2026-07-01T00:00:00+00:00", + "2", + "Tier 3", + "8", + "proposal_sent", + "2026-07-01T00:00:00+00:00", + ], + ] + } + memory.record_engagement(INTAKE, SCORE, source="/api/agent") + + updates = [c for c in sheet.calls if c[0] == "PUT"] + assert len(updates) == 1 + assert "clients" in updates[0][1] + row = updates[0][2]["values"][0] + assert row[6] == 3 # engagement_count incremented + assert row[4] == "2026-07-01T00:00:00+00:00" # first_seen preserved + assert row[9] == "proposal_sent" # operator-set status preserved + assert row[7] == "Tier 2" # rollup reflects latest score + + +def test_ledger_failure_skips_rollup_and_never_raises(sheet): + sheet.fail_on = "engagements:append" + memory.record_engagement(INTAKE, SCORE, source="/api/process") + assert not any("clients" in c[1] for c in sheet.calls if c[0] != "GET") + + +def test_rollup_failure_after_ledger_never_raises(sheet): + sheet.fail_on = "clients" + memory.record_engagement(INTAKE, SCORE, source="/api/process") + assert any("engagements:append" in c[1] for c in sheet.calls) + + +def test_record_without_company_name_is_noop(sheet): + memory.record_engagement(ClientIntake(), SCORE, source="/api/process") + assert sheet.calls == [] + + +# --- read path ----------------------------------------------------------- + + +def _sheet_with_history(): + return { + "clients": [ + memory.CLIENTS_HEADERS, + ["other|x.example", "Other", "x.example", "", "", "", "1", "", "", "", ""], + [ + "acme-corp|acme.example", + "Acme Corp", + "acme.example", + "Manufacturing", + "2026-07-01T00:00:00+00:00", + "2026-07-05T00:00:00+00:00", + "4", + "Tier 2", + "11", + "prospect", + "2026-07-05T00:00:00+00:00", + ], + ], + "engagements": [ + memory.ENGAGEMENTS_HEADERS, + *[ + [f"e{i}", "acme-corp|acme.example", f"2026-07-0{i}T00:00:00+00:00", "/api/process", "Tier 2", "11", "16", "[]", "[]", "$10k", "[]", "pending", ""] + for i in range(1, 5) + ], + ["ex", "other|x.example", "2026-07-05T00:00:00+00:00", "/api/process", "Tier 1", "14", "16", "[]", "[]", "", "[]", "pending", ""], + ], + } + + +def test_read_history_matches_composite_key_and_caps_engagements(sheet): + sheet.tabs = _sheet_with_history() + history = memory.read_client_history("Acme Corp", "https://acme.example") + assert history["client"]["engagement_count"] == "4" + assert len(history["engagements"]) == memory.HISTORY_LIMIT + assert [e["engagement_id"] for e in history["engagements"]] == ["e2", "e3", "e4"] + + +def test_read_history_falls_back_to_name_only_match(sheet): + sheet.tabs = _sheet_with_history() + history = memory.read_client_history("ACME CORP") # no website supplied + assert history is not None + assert history["client"]["client_id"] == "acme-corp|acme.example" + + +def test_read_history_unknown_company_returns_none(sheet): + sheet.tabs = _sheet_with_history() + assert memory.read_client_history("Nobody Inc") is None + + +def test_read_history_sheet_error_returns_none(sheet): + sheet.fail_on = "clients" + assert memory.read_client_history("Acme Corp") is None + + +def test_recall_summary_known_client(sheet): + sheet.tabs = _sheet_with_history() + summary = memory.recall_summary("Acme Corp", "acme.example") + assert summary["known_client"] is True + assert summary["client"]["last_tier"] == "Tier 2" + + +# --- agent wiring -------------------------------------------------------- + + +def test_build_agent_includes_recall_tool_only_when_enabled(monkeypatch): + import character + + monkeypatch.setenv("MEMORY_SHEET_ID", "sheet123") + agent = character.build_agent() + assert character.recall_client_history in agent.tools + + monkeypatch.delenv("MEMORY_SHEET_ID") + agent = character.build_agent() + assert character.recall_client_history not in agent.tools