Commit Graph

19 Commits

Author SHA1 Message Date
Keysat b5619d61e1 outreach: Outreach Draft Assistant — tailored LP drafts (v0.1.0:68)
First proactive-messaging build. New "Outreach" page (all authenticated users): pick an
investor + type (intro / follow-up / fund update / meeting follow-up / nurture) + optional
guidance; the agent drafts a tailored LP email in Ten31's voice, grounded in the thesis +
that investor's CRM notes and matched email history. The draft is editable + copyable;
nothing is sent (draft-only — guardrails #4, #6).

Sovereignty: the thesis is Ten31's own non-sensitive messaging (to Claude as-is); the LP
context is scrubbed through the redaction boundary before Claude, drafted with placeholders,
and re-hydrated locally — the LP list never reaches the API. Fails closed (scrub_unavailable /
claude_not_configured / rehydrate_failed quarantines a hallucinated-token draft).

Backend: mcp/outreach_agent.py (context assembly + scrub + Claude + rehydrate, reusing
architect_agent's client/thesis/voice + the Boundary); routes GET /api/outreach/investors,
POST /api/outreach/draft; logged. Test mcp/test_outreach.py (context assembly). Verified in
preview: page/selector/types/guidance render, fail-closed at the key-less Claude step (scrub
ran locally first), success rendering verified with a mocked ok draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:06:46 -05:00
Keysat 069e60053b email-activity agent: propose -> review -> approve grid notes (v0.1.0:64)
When a sent/received email is matched to an investor, a local-model agent drafts a
one-line dated note and queues it as a PENDING proposal (it never writes the grid
itself). On the Email Capture page a partner sees "Proposed grid notes", can edit the
text, and Approve (appends to that investor's grid notes cell, newest at bottom,
stamped with the approver) or Dismiss. Going-forward only: a cutoff (app_settings
email_activity_since, set on first run) means email dated before the feature was
enabled is never summarized, so the historical backfill makes no noise. Sovereign:
summaries run entirely on the local model (no redaction needed). Gmail sync interval
tightened 180 -> 15 min so outgoing email surfaces quickly.

Backend: migration 0002 (email_activity_proposals); propose_email_activity_notes()
runs via a new scheduler post_sync hook; list/decide functions + routes
GET /api/activity/proposals, POST .../{id}/approve|dismiss. Grid append stamps the
approving user (fundraising_state.updated_by has a FK to users). Test
test_email_activity.py (propose cutoff/idempotency, approve appends + edited note,
dismiss, already-decided guard) under FK enforcement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:55:26 -05:00
Keysat 3893a4fb9f system-status: show storage usage (DB, attachments, backups, disk free) — v0.1.0:63
/api/system/status now returns a best-effort storage block: database file size
(crm.db + WAL + SHM), the email_attachments dir, the backups dir, and disk
total/used/free via shutil.disk_usage(DATA_DIR). System Status renders a Storage
section with human-readable sizes so growth can be watched over time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:34:18 -05:00
Keysat bf829b784a grounding: wire matched email bodies into the LP-feedback corpus
_ground_feedback_corpus now pulls matched email bodies (the richest objection
signal) alongside communications and grid notes, round-robin merged so email is
never crowded out by a flat LIMIT, per-item capped at 4000 chars to keep the local
minimize tractable on long threads, and degrading gracefully when the email tables
are absent. Email remains Tier-2-sensitive: it only ever enters the redaction
boundary, never Claude directly. Inert until Gmail capture is enrolled. Not yet
deployed (bundles into the next release with the meeting-notes work).
Test: test_ground_corpus.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:30:29 -05:00
Keysat 196f1f6c65 thesis: seed 5 Architect positioning framings into the Workshop (v0.1.0:58)
Saves the 2026-06-05 Architect positioning pass as competing CANDIDATE options
under the core line's positioning variant group, beside Option A/B: Convergence
(47/60), Access (40), Asymmetry (36), Scarcity/chokepoints (35), Freedom-tech (28),
each with its red-team weakness inline. One-time, additive, non-canonical
(guardrail #4); idempotent via an interaction_log sentinel so a partner-deleted
option is never resurrected. ensure_positioning_framings runs after the v5 seed.
Test: test_positioning_framings.py (count/candidacy/idempotency/no-resurrection/log).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:40:25 -05:00
Keysat 6d6f4bcc7e Thesis Workshop redesign: edit/choose/delete + approve-as-current (v0.1.0:56)
Addresses Grant's feedback that the Workshop was confusing and underbuilt (no delete,
no approve, redundant generate-vs-feedback panels, and a stray "0" on segment lines).

Backend (architect_tools.py + server.py routes/handlers):
- retire_node: soft-delete a node + its subtree (reversible). DELETE /api/thesis/nodes/{id}.
- choose_variant: 'Use this' — keep this option, soft-delete the others in its group,
  mark it approved. POST /api/thesis/nodes/{id}/choose.
- upsert_thesis_node gains actor_type so a manual human edit is recorded as 'human'.
  PUT /api/thesis/nodes/{id} edits a part's text directly.
- handle_approve_line: one-click 'approve as current' — records this admin's approval on
  the line's in-review version (creating + submitting one from the live tree if none),
  promoting to canonical at the required distinct-approval count. POST /api/thesis/lines/{key}/approve.

Frontend (ThesisWorkshop redesign):
- Merged the redundant "Generate options" + "Give feedback" panels into one "Ask the
  Architect for options" box (revise was just generate-with-guidance).
- Per option: Use this / Edit (inline) / Delete. Per part: edit + delete via the same.
- "Approve as current" bar with dual-sign-off state + a "Current ✓" badge, and a one-line
  "how it works" hint. Refreshes the tree after every action.
- Fixed the stray "0": `{line.is_core && <badge>}` rendered 0 for non-core lines (SQLite
  integer 0); now `{!!line.is_core && ...}`.

Verified: backend test_thesis_actions.py (choose/edit/retire-subtree/dual-approval->canonical),
and a live in-browser smoke test (JSX compiles, Workshop renders, options show Use/Edit/Delete,
approve returns 1-of-2, no runtime errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:29:47 -05:00
Keysat 2e70b34592 Architect grounding boundary: redaction/re-hydration privacy gate (v0.1.0:55)
Phase 1 Workstream D. Lets the Architect ground the thesis in REAL recurring LP
objections without any LP identity reaching the Claude API. Layered, defense-in-depth,
fail-closed by construction (docs/redaction-rehydration.md).

backend/redaction/:
- scrub.py: the leak-proof core. Drops Tier-1 (labelled/structured account/wire/SSN/
  IBAN/SWIFT/passport, separator-tolerant); tokenizes known LP entities (dictionary from
  the canonical layer, unicode-folded + hyphen-extended) and structured PII (emails,
  scheme-less/social URLs, intl+ext phones, currency-cued amounts, ISO/worded/numeric/
  quarter dates, addresses, bare long digit runs); pre-neutralizes injected [TYPE_N]
  strings; single-pass rehydrate; metadata-only audit logging (the pseudonym map is the
  de-anon key — local-only, never logged/sent). Hardened across THREE adversarial
  leak-hunts (worded/coded amounts, intl phones, NFD/ligature/zero-width names, slash/
  comma SSN, SWIFT, alpha-prefixed accounts, substance-preserving false-positive fixes).
- client.py: Boundary — one scrub/rehydrate contract, SCRUB_BACKEND=local (default) or
  gateway (Spark Control /scrub + /rehydrate). Fails closed (db_path required; dictionary
  build errors propagate; strict rehydrate returns tokenized-not-de-anon text).
- test_scrub_leak.py, test_reidentification.py: golden-file leak + re-identification
  suites (synthetic only, guardrail #9), regression-locking every leak-hunt vector.

backend/mcp/architect_grounding.py: the flow — retrieve (local) -> minimize-first
(local Qwen) -> scrub (+ local-Qwen NER backstop for unknown names) -> Claude over the
de-identified register only -> re-hydrate locally -> human review. FAILS CLOSED if the
local model is unreachable or a hallucinated token appears. test_grounding_boundary.py
proves nothing sensitive reaches Claude and the three fail-closed paths.

server.py: POST /api/architect/ground (admin) wires retrieval -> ground_objections.
docker_entrypoint.sh: SCRUB_BACKEND (default local). docs/spark-control-scrub-endpoints.md:
the gateway handover spec (Option 1 — caller supplies the entity dictionary).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:06:29 -05:00
Keysat 300041a7ec Unification polish: LinkedIn in the grid inline contact editor (v0.1.0:54)
The fundraising grid's per-contact editor now has a LinkedIn URL field next to
name, email, title, and location. It threads through the grid contact object and
sanitize (which preserves contact-object fields), and _upsert_contact_from_fundraising
now reads and persists linkedin_url on both the update and insert paths — so a
LinkedIn entered in the grid lands on the linked contact record.

Test: test_grid_contact_link.py extended to assert LinkedIn entered in the grid
persists to the contact (idempotent). Frontend html.parser clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:24:50 -05:00
Keysat 49d384a0fb Seed the v5 thesis into the Architect Workshop (v0.1.0:53)
backend/thesis_seed.py builds the starting "living messaging source of truth"
from docs/thesis-seed-v5.md: a core line (throughline; the open Option A/B banner
as a competing variant group; the three pillars; the proof; voice rules), one line
per LP segment carrying that segment's angle, and the five segment definitions.

ensure_thesis_seed(conn) runs from init_db, seeding ONLY when the Workshop is empty
(no thesis lines) — idempotent and non-destructive, so it bootstraps once and never
overwrites partner edits. Everything lands draft/candidate; nothing is made canonical
(that stays the partners' dual-approval action, guardrail #4). Content is Ten31's own
messaging, not LP data.

Test: backend/test_thesis_seed.py runs init_db and asserts the core line, 5 segment
lines, the 2-member Option A/B variant group, 3 pillars, segment_cuts, and segment
defs, plus re-seed-is-a-no-op (11/11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:19:44 -05:00
Keysat 2afed210cb Grid/contacts unification step 1: real contact_id link + grid as front door (v0.1.0:52)
Structural fix for the duplicate-people class of bug: instead of matching a grid
contact "pill" to a contacts row heuristically by name/email (which drifted and
caused the 1406 double-count), link them by id.

Backend:
- Migration 0004: fundraising_contacts.contact_id (additive, nullable, logical FK
  to contacts(id)) + index. Paired down migration.
- sync_fundraising_relational now stores the id that _upsert_contact_from_fundraising
  already returns, so every grid contact carries its contacts-table id.
- _backfill_grid_contact_ids: one-time, idempotent backfill on startup (re-runs the
  grid sync once if any row lacks contact_id), so existing data links immediately.
- entity_resolution: grid pass prefers the explicit contact_id link (match_kind
  'grid_link') over heuristic email / name+investor, guarded by a PRAGMA check so
  older DBs without the column still work.

Frontend:
- Fundraising grid "+ Row" -> "+ Investor" (clear, single investor entry point).
- Contacts page: the "+ Add Contact" trigger is replaced by a pointer to the grid;
  the page is now a read/search/edit view (ContactDetailPanel still edits all
  fields). New people are added from the grid. No contact data is removed.

Tests: backend/ingest/test_entity_resolution.py extended (explicit-link case, 11/11)
and a new backend/test_grid_contact_link.py integration test (init_db applies 0004,
sync populates contact_id to the right contact, re-sync is idempotent). py_compile +
frontend html.parser clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:10:26 -05:00
Keysat d16264f401 Fix people double-count + duplicate-queue explosion (v0.1.0:51)
Root cause: grid contacts (fundraising_contacts) are the SAME people as the
contacts table (the app syncs them by name/email), but resolution matched grid
rows by (name + investor-canon) where the two sides derive the investor key from
different tables that rarely line up — so nearly every grid contact minted a
duplicate person (715 + ~692 ≈ 1406), and the duplicate finder then flagged each
twin against its real self (~676 candidates).

Fix (entity_resolution.py):
- Grid pass matches a grid contact to its existing contacts-table person by
  PROVABLE keys only (exact email, else exact name within the same investor) and
  records membership; on a miss it MINTS NOTHING (the old else-branch mint was the
  double-count source, and guessing by name across firms risks binding two
  different same-named people).
- Targeted, audited cleanup soft-deletes leftover grid-only "twins" (person rows
  with no 'contacts' link) and superseded pre-:48 'lp'/'organization' rows, guarded
  so any row carrying enrichment/human data is never dropped (guardrail #3); the
  tombstoned ids are logged to interaction_log (guardrail #5).
- _upsert_entity clears deleted_at on conflict so a re-emitted id is un-tombstoned
  (no permanent burial); fuzzy-merge losers stay buried via _redirect.

entity_merge.py / server.py: the duplicate queue + pending count now filter to
candidates whose both sides are still live, so self-healed twins drop out.

Verified: offline reproduction test (backend/ingest/test_entity_resolution.py,
10/10) reproduces the 1406-style doubling and proves it collapses; no regression
on the synthetic dev set; two adversarial review passes. Known pre-existing
identity-key weaknesses (same name+firm+no email collision; shared role inbox
over-link) are unchanged by this fix and will be resolved structurally by the
contact_id link in the grid/contacts unification.

Run "Build search index" after upgrading to recompute the canonical layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:49:39 -05:00
Keysat dd25bbc08d Architect agent: Claude-powered thesis generation (backend scaffolding)
- backend/mcp/architect_agent.py: generate_options + revise on Claude (prompt-
  cached thesis context, claude-opus-4-8, Ten31 voice rules). Writes N variant
  drafts to a node's variant group; nothing canonical without human approval.
  Fails gracefully if the API key / SDK is absent.
- server.py endpoints: GET /api/architect/status, GET /api/thesis/{key}/tree,
  GET /api/thesis/nodes/{id}/variants, POST .../generate, POST .../feedback,
  POST /api/thesis/lines, POST /api/thesis/lines/{key}/nodes. architect_tools
  gains get_node_variants.
- Dockerfile installs `anthropic`; docker_entrypoint loads ANTHROPIC_API_KEY from
  /data/secrets/anthropic-api-key (self-disabling until the key is dropped in).

Full HTTP surface verified end-to-end (graceful 502 without a key).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 13:25:47 -05:00
Keysat 91361042e7 Entity model: investors (grid) vs people (contacts); fix double-count (0.1.0:48)
Per Grant's clarification of the real data model:
- Investor entities come from the fundraising grid, one per row, all labeled
  "investor" (drops the confusing lp/organization split). Grid is source of truth.
- People come ONLY from the contacts table. The grid's contacts (fundraising_
  contacts) are matched to a contact-person and recorded as member_of links to
  their investor, instead of creating duplicate person entities. This fixes the
  ~doubled people count (people now ≈ contacts, not contacts + grid contacts).
- System Status cards: Investors / People (resolved) / Contacts in CRM / Grid
  contacts, so resolved-vs-source is visible at a glance.

Verified on synthetic: people == contacts count (no double-count); multi-contact
investors preserved via member_of.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 13:05:58 -05:00
Keysat 3c31b1e8a5 Soft-delete + source-count diagnostics; thesis v4 (0.1.0:47)
- DELETE handlers soft-delete (set deleted_at) + cascade contact -> opps/comms/lp
  instead of hard-deleting (guardrail #3); list queries filter deleted rows.
- ingest: chunking excludes soft-deleted records; qdrant delete-by-source-id;
  sync prunes soft-deleted records' vectors incrementally.
- /api/system/status returns raw source-record counts for sanity-checking.
- docs/thesis-seed-v4.md (no "bet" language, scarcity-forward, freedom-tech as
  a banner option, tightened pillars, reworked segments + edge).

Soft-delete verified via the running HTTP server (delete -> hidden + row kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:20:38 -05:00
Keysat cd3cca725c Phase 1: dual approval default, web-UI index jobs + merge review queue, thesis v2
- Dual sign-off is now the default (thesis_required_approvals defaults to 2).
- Entity-merge review queue (migration 0003): the fuzzy/Qwen tier no longer
  auto-merges — it writes CANDIDATES (entity_merge_candidates) with a same/different
  suggestion + confidence + reason for a human to approve (merge) or reject (keep
  separate). entity_merge.py applies/rejects (durable via entity_merges, soft-delete,
  repoint links+edges); decided pairs aren't re-surfaced.
- entity_jobs.py: UI-triggered background index jobs (rebuild/update/find-duplicates)
  as subprocesses with a one-at-a-time lock; status in /api/system/status.
- server.py: /api/index/{rebuild,update}, /api/entities/find-duplicates,
  /api/entities/merge-candidates [+ /{id} decide] — admin-gated.
- docs/thesis-seed-v2.md: concrete, plain-English rewrite per Grant's feedback.

Backend verified end-to-end on synthetic data (candidate gen -> approve/reject).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:14:12 -05:00
Keysat dd2c34d7bc Phase 1: investor↔contacts (member_of), system status, thesis seed v1
- entity_resolution: emit member_of relationship edges (contact -> investor),
  so one investor entity owns many contacts (institution) and a HNWI is the N=1
  case; crm_tools.get_investor_contacts + get_entity contacts/member_of; MCP tool.
- seed_synthetic: multi-contact institutions to exercise it (Harbor & Vine = 5).
- server.py: GET /api/system/status (index/entity/thesis/activity health) for an
  in-app status view (no shell needed to verify the index).
- docs/thesis-seed-v1.md: grounded v1 thesis (throughline, 6 pillars, objections,
  per-segment angles, voice) drawn from Ten31's newsletter/site/essays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:47:26 -05:00
Keysat 3e199fd8d5 Phase 1 Workstream A+E: thesis substrate + dual-approval gate
- migration 0002_phase1_architect: thesis_lines (core spine + per-segment lines),
  thesis_nodes (+ append-only revisions), thesis_versions (one-canonical-per-line
  DB invariant), thesis_reviews (dual approval + feedback), segments. Reversible.
- backend/mcp/architect_tools.py: agent draft tools (node tree, versions,
  segments, get_canonical fails-closed) — NO self-approval path. MCP-exposed.
- backend/thesis_review.py + server.py routes: human-gated approval. Dual sign-off
  via thesis_required_approvals; atomic supersede; every action logged.
- docs/PHASE_1.md (kickoff brief); docs/OPERATIONS.md (partner guide);
  start9/0.4 "Resolve duplicate names" fuzzy action.

Verified on synthetic data: dual approval promotes correctly, exactly one
canonical survives supersede, get_canonical fails closed, full interaction_log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:20:00 -05:00
Keysat c7ce44d963 Phase 0 foundation: canonical schema, ingest pipeline, CRM MCP server
Workstream A–C substrate for the Ten31 agentic system:
- A1: docs/crm-overview.md; CLAUDE.md conventions + guardrail #9
- A2: additive/reversible core migration (canonical_entities, entity_links,
  interaction_log, relationship_edges, soft-delete) + ledgered runner
- B1/B3: chunking + deterministic entity resolution (backend/ingest)
- B2: dense (bge-m3) + BM25 sparse ingest to Qdrant crm_chunks
- C: CRM MCP server (reads, retrieval modes, logged writes) — no outbound tools
- docs: redaction/re-hydration, Gmail enablement runbook
- synthetic test data; .env.example; housekeeping (.gitignore, untrack crm.db,
  drop legacy files + start9/0.3.5)

Verified end-to-end on synthetic data + live Sparks (hybrid > dense on entity
queries). Real backfill runs on Ten31 infra; index holds synthetic data only.
Branch snapshot also captures pre-existing working-tree changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:13:35 -05:00
MacPro 7027efd777 init local package repo 2026-02-27 12:44:50 -06:00