Compare commits

...

52 Commits

Author SHA1 Message Date
Keysat d388464fe4 Refresh Current state: mobile-first redesign thread + locked pipeline spec 2026-06-19 10:52:45 -05:00
Keysat 9777fe6e25 Align BRIEF.md grid card with locked investor model
Drop 'type badge'/INVESTOR-PROSPECT category and the create-flow 'type'
field (no investor type — Existing-Investor is auto-derived from
committed $). Card now specs the existing-investor star/accent, Priority
as the only corner badge, the 4-stage chip shown only when in pipeline,
and last-contact staleness (grey->amber->red, 'Nd stale').
2026-06-19 09:13:59 -05:00
Keysat 168336c318 Capture one-off feature batch + lock pipeline-stages/flags redesign spec
Triaged eight one-off ideas (2026-06-18) into ROADMAP; #6 (spark-control
dashboard card) routed to standards/INBOX. Sharpened the pipeline-stages
idea into a locked spec (2026-06-19): 4-stage per-investor funnel
(Lead/Engaged/Diligence/Commitment), auto-derived Existing-Investor flag,
Priority+Graveyard disposition (Longshot dropped), staleness as a derived
recency overlay + W1b Matrix nudge (never auto-demotion), one global stale
threshold, and the card-presentation decisions. AGENTS Current-state notes
the built-pending view reorder + the captured batch.
2026-06-19 08:57:15 -05:00
Keysat c7f959d7d5 Add drag-to-reorder for fundraising grid views
Sidebar view list is now drag-reorderable (HTML5 DnD mirroring the
column-reorder idiom: moveViewBefore + draggingViewId/dragOverViewId).
Order persists via the grid page's existing autosave (views is already
in its snapshot + deps), the same path rename/delete use; no backend
change. Render-smoke green.
2026-06-19 08:57:15 -05:00
Keysat 99404db48b Add mobile-first design contract and redesign brief
Scaffold design/ for the frontend's first design contract, extracted
as-built from index.html (document-as-is):
- DESIGN.md: 9-section brand brief (dark venture-CRM look, IBM Plex,
  single #3b82c4 accent) + tokens.tokens.json (DTCG, from :root + an
  inline-style census).
- BRIEF.md: the mobile-first redesign packet. Mobile = 4 surfaces
  (Grid, Pipeline, Reminders, Contacts) in a bottom tab bar; the rest
  desktop-only. Grid view-switching first-class; narrow on-the-go edit
  set (name, contacts, notes/comms/outreach log, stage, reminders) +
  create-investor, all via the canonical grid path (Contacts stays
  read-only). Includes a backend-reality callout: no field-level write
  (whole-grid versioned PUT vs the targeted log-communication path),
  stage is a separate two-call opportunities flow, pill removal has no
  undo, dedup typeahead is client-side.
- brand/ assets, inspiration/ provenance.

Wire the AGENTS.md Design line so any agent reads the contract before
UI work.
2026-06-18 21:50:34 -05:00
Keysat ab0d82ff00 Mark v0.1.0:94 deployed (NL-query matched-only fix live on the box) 2026-06-18 20:27:38 -05:00
Keysat 9d0d3068fb Bump package version to v0.1.0:94 (NL-query matched-only fix)
Ships the comms_by_user / email_counts_by_user matched-only fix to the box.
No schema change, no UI change — version migrations are no-ops.
2026-06-18 20:25:34 -05:00
Keysat 2d43bad6fc Restrict comms_by_user/email_counts_by_user to matched-investor email
Both NL-query intents counted/listed a user's ENTIRE captured sent corpus
(internal, vendor, personal mail) rather than only email to a matched investor
— they were missing the `EXISTS email_investor_links` gate that recent_emails
and the Communications panel's query_email_activity use. Their own docstrings
said "investor emails", so the behavior was wrong, not just loose.

Add the matched-only gate to both, mirroring query_email_activity. The runner
test now seeds an unmatched sent email and asserts it is excluded (without the
fix comms_by_user returns 3 not 2, this_week 2 not 1) — the prior fixture
linked every email, so the leak went uncaught.

Also documents the matched-only rule in the nl-query guide, and refreshes the
AGENTS.md Current state (v93 deployed; this fix pending a v94 s9pk since the
intents run on the box, not the bot).
2026-06-18 20:24:52 -05:00
Keysat f7b03ee109 Bump package version to v0.1.0:93 (reminders + NL-query)
Ships the next s9pk for the box, which jumps from v91 and so bundles two
in-repo-but-undeployed workstreams:
  - W1 reminders & follow-ups (v92): in-app migration 0006 (additive — a new
    `reminders` table + indexes; verified up/down against a copy of crm.db).
  - W2 natural-language query: read-only POST /api/query/nl + /api/query/catalog
    (require_bot_or_admin, audited), local-model translation, no schema change.

The Matrix Q&A client for W2 ships separately on the Spark and depends on this
endpoint being live on the box.
2026-06-18 19:56:53 -05:00
Keysat 68106d7a5a Add Matrix NL-query Q&A surface (W2 step 5)
Read-only natural-language query over the curated nl_query endpoint, answered
in-thread. Two entry points (room-per-purpose model): a dedicated Q&A room
(MATRIX_QUERY_ROOM) where every top-level message is a question, plus the
?/@bot trigger in the intake room as a cross-room convenience. Both routes hit
the same handle_query -> crm_client.nl_query -> POST /api/query/nl; translation
runs on the box's local model, nothing leaves the box, and there is no write
path so no approval gate applies.

Pure logic (trigger parsing, answer rendering) in query.py with offline tests;
async room wiring in bot.py (live-smoke only, per the bot's convention).

Bot-side only, ships on the Spark via git pull + restart. Depends on the
box-side /api/query/nl endpoint, which lands with the v93 s9pk (reminders + W2):
until v93 is installed the Q&A surface 404s, so the bot deploy is staged to
follow that install.
2026-06-18 19:46:54 -05:00
Keysat 6c29c22601 Add NL-query backend (W2): local translator + safe named-query runner
Read-only "ask the database in plain English" backend. Translation runs on
the local Qwen via Spark Control (question -> {intent, slots}); nothing leaves
the box, no Claude and no redaction boundary (the simplification chosen after
pressure-testing). The safe surface is a curated catalog of ~12 hand-written
parameterized queries; a slot validator is the trust boundary (no generic SQL,
no dynamic identifiers). POST /api/query/nl + GET /api/query/catalog, gated
require_bot_or_admin, read-only, audited. Soft-delete-correct per table.
Local Qwen translated 12/12 real example questions correctly against the live
Spark. Web "Ask" box and Matrix bot still to come (steps 4-5).
2026-06-18 18:35:41 -05:00
Keysat a166b49397 Handoff: record reminders W1 (v0.1.0:92, deploy pending); next = deploy + W2 NL-query 2026-06-18 14:48:17 -05:00
Keysat f181525926 Add reminders & follow-ups (W1) (v0.1.0:92)
First-class reminders tied to the fundraising grid — foundation of the agreed
reminders -> NL-search -> bot-mutations plan (keep LP data off third-party LLMs).

- reminders table (migration 0006; logical FK to fundraising_investors.id +
  denormalized name), CRUD at /api/reminders (soft-delete; open/done/snoozed/
  cancelled; assignee; source; source_row_id resolution)
- read-only derived reminder_status grid column (overdue/due_soon/open),
  filterable; orphan reconciler cancels reminders when an investor leaves the grid
- Reminders page, Dashboard "Reminders Due" card, daily-digest reminders section
- per-investor last_activity_at recency rollup (shared block for the W2 NL query)
- tests: test_reminders.py + digest reminders test (31/31 green, render-smoke green)
2026-06-18 14:45:46 -05:00
Keysat ee6a4e52d2 Handoff: email-proposal Matrix review live (v0.1.0:91); bot role + whole-thread redaction
Durable updates after the email-proposal review session:
- AGENTS.md: roles admin/member -> admin/member/bot; add a Conventions entry on
  the bot role and the reach(role)-vs-autonomy(approval gate) principle.
- matrix-intake guide: rewrite the bridge section to final behavior (redact_thread
  whole-thread redaction, the Element 'show deleted' client-setting dependency for
  full clearing, the redact_resolved.py backfill tool, deploy gotchas).
- Current state rewritten lean (14->8 bullets); test count 27->30.
2026-06-18 12:51:46 -05:00
Keysat b2690c4342 Redact whole review threads on decision (replies too)
The bot was granted a redact/mod power level in the review room, so it can now
clear a resolved thread entirely, not just the card: redact_thread redacts the
card root then scans recent history for its m.thread replies (the human's yes/no
+ any bot messages) and redacts those too, so decided threads drop out of the
threads view, not only the main timeline. Drops the in-thread confirmation on a
successful decision (the thread clearing is the ack; a confirmation would keep
the thread alive). redact_resolved.py extended to also clear replies of already-
resolved threads for the one-time backfill. Bot-only; no s9pk change.
2026-06-18 12:32:06 -05:00
Keysat 9044641b08 Add one-time tool to redact resolved review cards
Cards decided before the auto-redact behavior shipped are already 'closed' in
the CRM, so the bot's to_close sweep never redacts them. redact_resolved.py walks
the review room, keeps cards still pending (CRM 'open' list), and redacts the
rest. Dry-run by default; --apply to act. Run via docker compose on the Spark.
2026-06-18 12:09:48 -05:00
Keysat a10889b10b Refine email-proposal review UX (v0.1.0:91)
Three post-smoke refinements to the Matrix email-proposal review:

1. Dash separators (bot): every card/reply is framed with a dash rule top and
   bottom so threads stop bleeding together vertically on mobile.

2. Remove decided threads (bot): on a conclusive approve/dismiss from either
   surface, the bot redacts the card (client.room_redact) so the room clears
   down to only undecided items. Redacting the bot's own card needs no power;
   the web->Matrix path now redacts instead of posting a closure note.

3. Clearer note wording (server v91 + bot): the proposed grid note now names who
   emailed whom -- "{teammate} emailed {investor}" (outbound) / "{sender} emailed
   the team" (inbound) -- instead of an ambiguous "Sent"/"Received". Outbound
   detection also matches our corporate domain (public providers excluded), so a
   teammate's mail from a non-enrolled @ten31.xyz address no longer reads as
   "Received". Going-forward only; no schema change. The card drops its bare
   direction label since the note now carries the relationship.

Tests updated; 30/30 green, render-smoke green.
2026-06-18 11:59:38 -05:00
Keysat 48bd29aaa3 Record email-proposal Matrix review deployed & live (box v0.1.0:90) 2026-06-18 11:21:38 -05:00
Keysat 29987061cb matrix-intake: bot joins the email-review room on startup
room_send to a room the bot is only invited to (not joined) fails M_FORBIDDEN;
join explicitly on startup (idempotent if already a member). Bot-only change —
ships via the Spark git pull, no s9pk bump.
2026-06-18 10:33:19 -05:00
Keysat 27e9ea5b0b Add 'bot' to the admin edit-user role dropdown (v0.1.0:90)
v89 added the 'bot' role for the Matrix email-review bot's endpoints but kept it
out of the UI, leaving no click-path to assign it. Add 'bot' to the Settings ->
Admin edit-user role dropdown (the teammate-invite form stays member/admin only —
provisioning an agent account is an admin re-classification of a dedicated user,
not a teammate invite). The backend update validator already accepts 'bot'.
Frontend-only, no schema change.
2026-06-18 10:13:30 -05:00
Keysat 5faa5ae4d6 Email-proposal review over Matrix + a bot role (v0.1.0:89)
The email-capture "proposed grid notes" gain two review surfaces:

1. Inline source email — each proposed-note card on the Email Capture page
   gets a "View email" toggle that lazily fetches the existing
   GET /api/email/detail and shows from/to/cc/date/subject + scrollable body,
   so a reviewer can judge the note against the email it was drafted from.

2. CRM->Matrix review bridge — the CRM (box, stdlib, no matrix-nio) can't post
   to Matrix, so the intake bot (Spark) PULLS: GET /api/intake/email-proposals
   returns to_post/open/to_close work-lists; the bot posts a review card
   (metadata + snippet + draft note) to a dedicated review room
   (MATRIX_EMAIL_REVIEW_ROOM) and relays in-thread yes / no / NL-edit
   (POST .../{id}/decide, note revised via local Qwen). Decisions sync both
   ways: web decide -> bot announces + closes the thread; Matrix decide -> the
   web panel's ~25s poll clears the card. State lives CRM-side in the new
   email_proposal_matrix side row (email-integration migration 0003, additive
   + idempotent CREATE TABLE IF NOT EXISTS), so it survives a bot restart.

Adds a 'bot' role (authenticated, never admin; require_bot_or_admin) to gate
the email-proposal endpoints rather than handing the bot full admin — the
principled base for the coming agentic capabilities. Role controls reach;
the draft->approve gate still controls autonomy (a human approves every write).

Deploy split: endpoints + migration + role + frontend ship in the s9pk; the
bot poll loop + review-room handling ship on the Spark. The bot's CRM user
must be flipped member->bot and joined to the review room (one-time).

Tests: backend/test_email_proposal_matrix.py + matrix_intake/test_email_proposals.py
(30/30 suite green, render-smoke green, migration verified twice on a DB copy).
2026-06-18 09:51:41 -05:00
Keysat 41def0f014 Handoff: Adopt the Pipeline done — v88 verified live, full round-trip smoked
Box and repo on v0.1.0:88; the +Pipeline -> board -> advance-stage -> remove
round-trip is verified on the box. Pipeline adoption is closed out; ROADMAP
item marked done and the Next list advances to the spark-control intake card.
2026-06-18 08:34:32 -05:00
Keysat 114916b789 Retire the Pipeline page's "+ New Opportunity" button (v0.1.0:88)
Opportunities are now born only from a fundraising-grid investor row
("+ Pipeline"), which matches how the team works — they live in the grid,
not on the board. The old "+ New Opportunity" button created a deal by
picking a contact, a path that contradicts the grid-is-canonical model and
the contact-vs-investor framing.

Remove the button, its create-by-contact modal, the now-dead handler/state,
and the Pipeline page's unused /api/contacts fetch. Replace the button with a
muted "Add deals from the Fundraising Grid" hint. The board is now a view +
stage-management surface. Frontend-only; no backend or schema change.

Render-smoke green.
2026-06-18 08:25:14 -05:00
Keysat 4df104b119 Record Adopt-the-Pipeline deployed live (v0.1.0:87)
Box and repo now on v0.1.0:87: StartOS chain ...86->87 clean,
0005_grid_pipeline_link.sql applied on the box, server up on :8080.
Next is an in-room/board live-smoke of the Add-to-Pipeline round-trip.
2026-06-18 07:54:19 -05:00
Keysat 7f9a15ebf3 Adopt the Pipeline: grid-driven opportunities link (v0.1.0:87)
The fundraising grid (canonical) now drives the classic opportunities
Pipeline board, instead of the board being a disconnected second data-entry
surface. An "Add to Pipeline" row action creates a durably-linked opportunity
via the new opportunities.fundraising_investor_id (migration 0005, additive +
reversible), reusing the grid's already-synced contact — retiring the
POST /api/contacts side-door — and mapping the grid lead to the opp owner.

Ownership is split so the two stay reconciled: the grid owns whether the link
exists and the seed; the board owns stage/probability/owner. The link endpoint
is idempotent (one live opp per investor; a re-link never reseeds funnel
fields). "Is in pipeline?"/"what stage?" are derived from a live opp join and
injected as read-only grid columns on read, stripped on write, so they never
persist or dirty the autosave. Remove-from-pipeline soft-deletes the opp and
leaves the grid row fully intact; deleting an investor from the grid archives
its orphaned opp.

Also fixes the standing soft-delete leak in handle_pipeline_report and the
dashboard pipeline aggregates, which counted tombstoned opportunities.

Tests: backend/test_grid_pipeline_link.py (link/idempotent/round-trip/guards/
unlink-intact/re-link/orphan/aggregates); 28/28 suite green, render-smoke green.
2026-06-17 23:08:36 -05:00
Keysat 06482247df Handoff: record team-roster parse frame deployed & smoked; reprioritize Next 2026-06-17 22:17:44 -05:00
Keysat c1ea1769a4 Matrix intake: frame parse with team roster so a teammate isn't read as the prospect
Local-smoke found "jonathan is chatting with wyoming" extracted the teammate, not
the prospect. Feed the parser an optional team roster (INTAKE_TEAM_ROSTER) via a
build_system(roster) outreach frame: roster names/initials are the people doing
outreach and are never extracted; the other party is the investor/prospect. Same
framing on the revise leg. Unset roster = prior behavior.
2026-06-17 21:58:54 -05:00
Keysat b376b8ce33 Handoff: prune Current state to a snapshot; note shared .dockerignore gotcha 2026-06-17 20:30:39 -05:00
Keysat cae2dbc8b9 Record intake-bot containerization; log parse fix, card handoff, and repo-extraction follow-ons
Bot now runs as a docker-compose service on the Spark (verified live, listening). Docs (matrix-intake guide ops, ROADMAP, AGENTS Current state) updated. Also logs the live-smoke parse bug (teammate read as investor -> team-roster fix), the spark-control dashboard-card handoff, and the long-term dedicated-repo extraction.
2026-06-17 20:13:35 -05:00
Keysat b470ea2659 Containerize the Matrix intake bot as a managed service (restart: unless-stopped)
Turn the bot from a bare nohup process (silently dies on a Spark reboot) into a docker-compose service. Dockerfile bundles backend/matrix_intake + the stdlib backend/ingest Spark client it reuses; .env is mounted read-only at runtime, never baked. The existing repo-root .dockerignore (shared with the s9pk build) already keeps data/ and .env out of context. Also adds a handoff doc for wiring a spark-control dashboard card in a later session.
2026-06-17 20:10:16 -05:00
Keysat a7b03837b3 Record v0.1.0:86 deploy: Matrix intake fuzzy + conversational pass live on the box + Spark
Box installed to 0.1.0:86 (migration chain ...85->86 clean, candidates endpoint verified live); bot pulled + restarted on the Spark. Only the Matrix live-smoke remains.
2026-06-17 18:55:51 -05:00
Keysat 0b893295e1 Matrix intake: fuzzy investor matching + conversational in-thread edits (v0.1.0:86)
Close the two locked post-deploy enhancements for the Matrix intake bot.

Fuzzy matching (server-side, ships in the s9pk): new find_intake_candidates in
server.py returns ranked deterministic near-matches (difflib name similarity +
token-set Jaccard, legal-suffix-aware, + email Levenshtein <= 2); GET
/api/intake/match now returns {match, candidates}. The bot surfaces a numbered
shortlist so a near-duplicate (Charlie/Charles, Acme Capital vs Acme Capital LLC,
a one-char email typo) is confirmed by a human instead of silently creating a
second investor. Exact match still auto-attaches; fuzzy candidates are never
auto-attached. The optional LLM-judge re-rank is deferred.

Conversational edits (bot-side, ships on the Spark): any in-thread reply that
isn't yes/no/edit field=value is treated as a natural-language revision and
re-run through local Qwen (parse.revise). Email integrity is preserved -- a
changed address must literally appear in the instruction; the model's email
field is structurally unreachable. No-op revisions re-prompt.

Docs/current-state brought current; 27/27 backend tests green.
2026-06-17 18:50:58 -05:00
Keysat fa6c9da0e6 Drop redundant "[note]" tag from fundraising-grid note line (v0.1.0:85)
The grid note line was "YYYY-MM-DD [type] Contact: summary"; for the default
"note" type the tag is noise. Omit it for "note"; keep it for informative
types (call, meeting, …). Shared by the Matrix intake bot and grid-UI logging.
Built + installed to the box (installed-version 0.1.0:85, clean 84->85
migration). No schema change.
2026-06-17 17:30:40 -05:00
Keysat aefb2aa119 Matrix intake: main-timeline nudge, clearer messages, note text in grid
Four bot-side UX fixes surfaced by the live smoke:
- Post a brief pointer in the main timeline (a reply to the user's message)
  alongside the in-thread proposal card, so proposals aren't missed inside a
  thread. Pointer only — approvals still happen in the thread, where the note
  is visible (you can't make an informed yes/no without seeing it).
- A bare yes/no typed in the main timeline while a proposal is pending now
  gets a "reply in the thread" redirect instead of "couldn't tell what to record."
- Clearer commit confirmations: "Created a new grid entry for X" vs
  "Logged a note on X (existing grid entry)."
- Send a blank communication subject when a note is present so the grid's
  one-line note summary shows the note text, not the "(Matrix)" label
  (provenance stays in source="matrix_intake").
2026-06-17 17:14:08 -05:00
Keysat 13326cbdc6 Ship Matrix-intake CRM endpoints to the box (v0.1.0:84)
The intake bot's server-side dependencies — GET /api/intake/match (new-vs-
existing lookup) and `source` provenance on log-communication — shipped in
source with 7ad0ee7 but were never packaged. The box ran v83 (pre-7ad0ee7),
so the bot's match calls 404'd: a note on an existing investor would have
created a duplicate, and writes weren't tagged matrix_intake. Bump + build +
install verified live (installed-version 0.1.0:84, clean 83->84 migration,
match endpoint now resolves by name and email). No schema change.

Also log the conversational (LLM-mediated) edit enhancement in ROADMAP.
2026-06-17 15:33:06 -05:00
Keysat fd2e3ed78e Matrix intake: strip surrounding punctuation from extracted emails
normalize()'s email regex matched non-@/non-space runs, so "Name <addr>"
(the most common contact format) yielded "<addr"; only trailing punctuation
was stripped, never leading. Tighten the regex to standard local@domain.tld
so the bare address is extracted from <…>, (…), and trailing-period forms.
Found via the live-deploy pre-flight. Add a regression test.

Also log two intake backlog items in ROADMAP: the scoped service-credential
auth path (deferred; bot uses a member login for now) and fuzzy match +
in-thread confirm (post-deploy).
2026-06-17 14:06:32 -05:00
Keysat 7ad0ee7624 Add Matrix intake bot (M1+M2): typed message → approved fundraising-grid write
New backend/matrix_intake/ runs as its own process (matrix-nio isolated from the
stdlib CRM): local-Qwen parse via Spark Control → in-thread human approval
(yes/edit/no) → write through the CRM's own log-communication endpoint, tagged
source=matrix_intake. Adds read-only GET /api/intake/match (returns grid row id,
no-duplicate contract); threads provenance through handle_log_fundraising_communication.
Reviewer-passed: pop-before-commit closes a double-approve race; edit-grammar fix.
Text-only v1; business-card photo (M3) deferred (no Spark vision model).
26/26 tests green; live Matrix smoke pending deploy.
2026-06-17 07:51:27 -05:00
Keysat 172c76553b Triage inbox: correct networking facts, log icon/email-sync/Matrix-bridge items
Fix AGENTS.md access wording (ClearNet/StartTunnel + app-level auth, not
LAN/Tailscale). Add the StartOS icon bug to Known debt and the email-sync-status
error as Next #8. Add ROADMAP sections for Matrix-bridge grid intake (next,
high priority) and an admin-vs-all-users UI audit.
2026-06-16 22:24:33 -05:00
Keysat 6e18d8ddd4 Refresh Current state for v0.1.0:83 (deployed + validated; digest auto-send on) 2026-06-16 21:23:31 -05:00
Keysat c7b74a2704 Email search/query + windowed digest preview (v0.1.0:83)
Communications tab (search/query roadmap items 1 & 2):
- Fix the investor dropdown: the facet only listed grid investors, so it
  came back empty whenever email matched a classic contact or org domain
  (no grid id — the common case). It now mirrors the email list, resolving
  each link to a typed identity (fund:/org:/contact:/addr:) with precedence
  grid -> org -> contact -> address; investor_id accepts the typed key
  (bare id = fund: for back-compat) and an unknown prefix matches nothing.
- Add a date-range filter and a click-to-expand full-body view
  (GET /api/email/detail, admin, soft-delete-gated; body_text only, never
  raw remote HTML).
- Add a "Search content" mode: GET /api/email/search wraps the ingest
  hybrid_search over the Qdrant email index (doc_type=email), hydrated and
  soft-delete-filtered against SQLite (canonical), 503 if Spark/Qdrant down.

Daily digest:
- Settings -> Admin builds a digest over a chosen window (last 24h or since
  a date) as an in-app preview before sending (POST /api/admin/digest/preview),
  so the local-Spark summarizer can be verified on demand even on a quiet day.
  Manual send uses the same window; neither advances the daily cursor, so a
  preview never suppresses the scheduled digest.

Code-only, migrations no-op. 22/22 backend tests, render-smoke pass.
2026-06-16 20:46:15 -05:00
Keysat c29ac2f2ee Refresh Current state for v0.1.0:82; document render-smoke build gate
Record the v82 vendor+SRI + render-smoke work in durable docs: packaging guide
gains the verified-build gate + re-vendor instructions; Current state rewritten
and compressed for v82; ROADMAP logs the deferred pre-compile-JSX alternative.
2026-06-16 16:43:10 -05:00
Keysat 40a0270a99 Vendor + SRI-pin front-end libs; add render smoke gate (v0.1.0:82)
React/ReactDOM/Babel were loaded from the unpkg CDN at runtime — react@18
and react-dom@18 weren't even exact-pinned, and none had SRI. A CDN swap (or
react auto-resolving a new 18.x) could blank the whole app with no change on
our side: exactly the v78/v79 blank-screen class. It also made the self-hosted
box depend on outbound internet to render.

Vendor the three libs into frontend/assets/vendor/ (React 18.3.1, ReactDOM
18.3.1, @babel/standalone 7.29.7) and load them same-origin with sha384
integrity attributes. They now ship inside the s9pk (Dockerfile already COPYs
frontend/; server.py serves /assets/* with the path-containment check), so a
CDN can never swap prod deps again and no outbound fetch is needed at runtime.

Add start9/0.4/render-smoke.mjs: a jsdom render smoke check that (1) runs the
shipped Babel over the app's inline JSX and asserts a classic, non-module,
parseable script (the v79 ESM-import regression), and (2) mounts the app in
jsdom and asserts the login UI renders (the v78 blank-screen class). Wired into
the default `make` goal so every package build is gated on the frontend
actually rendering — closing the "verified live via curl only" gap. jsdom is a
build-time devDependency, not shipped in the image.
2026-06-16 16:10:26 -05:00
Keysat 45fd037e3b Refresh Current state for v0.1.0:81 (matched-only Communications tab) 2026-06-16 15:53:44 -05:00
Keysat 6563a7811e Communications tab: show matched investors only (v0.1.0:81)
The email-activity panel surfaced every captured message, including cold/
unknown-sender email with no investor association. Gate query_email_activity
on EXISTS(email_investor_links) so the panel shows only email tied to a known
investor/contact. Capture is unchanged — unmatched email is still stored
(metadata-only) and will appear automatically if its sender is later added as
an investor; this is a read-side filter only.

Graveyard investors are unaffected (their email has a link), so they remain
visible/searchable as an audit surface, hidden only from the filter picker.
2026-06-16 15:43:30 -05:00
Keysat def7c9ea6a Document email-activity panel semantics in email guide 2026-06-16 15:26:05 -05:00
Keysat 42d2b4b245 Repurpose Communications tab as admin-only email-activity panel (v0.1.0:80)
The Communications tab is now an admin-only search over captured Gmail
(email_* tables), part of consolidating on the fundraising grid + email
capture as the canonical system of record.

- New GET /api/email/activity (admin-enforced server-side): filter by
  investor / mailbox / direction with free-text search over subject,
  snippet, and sender. Query logic in db.query_email_activity.
  - Soft-delete honored on the per-mailbox sighting (emails carry no
    deleted_at; deletion lives on email_account_messages).
  - Direction decided at the email level (outbound if the sender is one of
    our mailboxes), mirroring digest_builder.
  - Graveyard investors are hidden from the filter dropdown (CRM-wide
    graveyard=0 convention) but their email stays visible in the list and
    findable by free-text search — this is an audit surface.
- Communications page rewritten to render the panel; the classic manual
  "Log Communication" form is retired (the grid context menu remains the
  manual-log path). Nav item + page are admin-only.
- Tests: email_integration/test_email_activity_panel.py (filters,
  per-sighting soft-delete, roll-ups, graveyard handling, route 401/403);
  full suite 22/22. Frontend render verified via a jsdom mount smoke test
  plus the pinned classic-runtime Babel transform.

Code-only, no schema migration (version migrations are no-ops).
2026-06-16 14:49:59 -05:00
Keysat f9705d2216 Refresh Current state for v0.1.0:79 (blank-screen hotfix + admin gaps)
Record the Babel-pin fix + root cause, the 3 newly admin-gated GET endpoints, the corrected deploy-verification convention (browser render, not curl/health), and the re-ordered Next list (vendor+SRI, auth regression test, email-activity panel in the admin-only Communications tab).
2026-06-16 14:07:32 -05:00
Keysat cc25be4e14 Fix blank-screen on load + close 3 admin gaps (v0.1.0:79)
The web UI rendered a blank screen for every user. Root cause: the page
loaded @babel/standalone from unpkg with no version pin, so the CDN silently
served Babel 8.0.0. Babel 8 defaults @babel/preset-react to the automatic JSX
runtime, which prepends `import {jsx} from "react/jsx-runtime"` to the compiled
output. An ESM import is illegal in this classic (non-module) inline <script>,
so the browser rejected the whole bundle and React never mounted — hence the
blank screen. The prior "verified live" checks were server-up/curl, which can't
catch a browser-render failure.

- Pin @babel/standalone@7.29.7 (its preset-react defaults to the classic
  React.createElement runtime). Verified via headless render: app mounts, login
  screen renders, no console error. Follow-up: vendor + SRI-pin the CDN libs so
  a third party can't swap our front-end deps in production again.
- Close three server-side admin gaps surfaced by a permissions audit — endpoints
  that were UI-hidden from members but not API-enforced: GET /api/users,
  /api/email/status, /api/email/accounts now require_admin. Removed the now-dead
  non-admin mailbox-row filter. 21/21 backend tests green; py_compile clean.
2026-06-16 12:59:55 -05:00
Keysat da052a181b Handoff: record grid-canonical investor model; refresh Current state for v0.1.0:78 2026-06-16 11:14:15 -05:00
Keysat a5a9b06423 Refresh AGENTS.md Current state for v0.1.0:78 (grid-canonical decision; lp_profiles retired) 2026-06-16 11:01:09 -05:00
Keysat c23384498b Mark v0.1.0:78 deployed & verified live (lp_profiles/LP Tracker retired) 2026-06-16 10:51:01 -05:00
Keysat 108210d8e1 Retire lp_profiles + LP Tracker; repoint Dashboard committed to the grid (v0.1.0:78)
The fundraising grid + email capture is the canonical system of record. lp_profiles
was a superseded single-fund model with no reachable create/edit path, and the LP
Tracker page was already orphaned (no nav entry + a redirect bouncing it to the grid).

- Remove /api/lp-profiles* endpoints + handlers, the unused lp-breakdown report,
  the contact-dossier LP section, the demo-seed LP block, and (frontend) the
  LPTrackerPage component + its lp-tracker->fundraising-grid redirect.
- Dashboard "Total Committed" now sums fundraising_investors.total_invested
  (graveyarded investors excluded) instead of the orphaned lp_profiles table, which
  read ~$0. "Total Funded" dropped: the grid tracks commitments, not a funded amount,
  and the frontend never rendered it.
- Leave the empty lp_profiles table/index, the contact-delete soft-delete cascade,
  and the --reset-all-data clear in place (never-hard-delete).
- Tests: add test_dashboard_report.py; update test_soft_delete_reads.py. 21/21 green.
2026-06-16 10:48:53 -05:00
92 changed files with 10826 additions and 1062 deletions
+1
View File
@@ -0,0 +1 @@
../../docs/guides/matrix-intake.md
+1
View File
@@ -0,0 +1 @@
../../docs/guides/nl-query.md
+34
View File
@@ -47,3 +47,37 @@ SMTP_SECURITY=starttls
SMTP_FROM= SMTP_FROM=
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
# ── Matrix intake bot (backend/matrix_intake/, runs as its own process on the Spark) ──
# Parses a typed message in a dedicated Matrix room into a proposed fundraising-grid
# add/edit (local Qwen via Spark Control above), then writes through the CRM API only
# after in-thread human approval. Reuses SPARK_CONTROL_URL / CRM_CHAT_MODEL above.
MATRIX_HOMESERVER=https://<homeserver>
MATRIX_USER=@intake-bot:<homeserver>
MATRIX_ACCESS_TOKEN=
MATRIX_DEVICE_ID=ten31-intake-bot
MATRIX_INTAKE_ROOM=!<roomid>:<homeserver>
# Dedicated room for reviewing CRM-drafted email-activity proposals (the proposed grid notes the
# Email Capture panel shows). The bot posts a review card per pending proposal here and relays the
# in-thread yes/no/edit back to the CRM, in sync with the web panel. Separate from the intake room
# so high-volume email proposals don't drown the conversational intake. Leave empty to disable the
# whole email-review poll loop. The bot must be a member of this room. Needs the server side in the
# s9pk (≥ v0.1.0:89) and the bot's CRM user set to role 'bot' (see docs/guides/matrix-intake.md).
MATRIX_EMAIL_REVIEW_ROOM=!<roomid>:<homeserver>
# Dedicated read-only Q&A room (W2): every top-level message here is answered as a natural-language
# query (translated on the box's LOCAL model — nothing leaves the box), no '?'/'@bot' trigger needed.
# The '?'/'@bot' trigger still also works in the intake room. Leave empty to disable the dedicated
# room (questions then go via the intake-room trigger). The bot must be a member of this room. Needs
# the server side in the s9pk (POST /api/query/nl) and the bot's CRM user set to role 'bot'.
MATRIX_QUERY_ROOM=!<roomid>:<homeserver>
# CRM write-back: the bot logs in as a DEDICATED service user (admin-created CRM user;
# the CRM has no service-key path, so it uses normal Bearer-JWT auth).
CRM_API_BASE=http://127.0.0.1:8080
CRM_BOT_USERNAME=
CRM_BOT_PASSWORD=
# Set to false only if CRM_API_BASE is https with a self-signed cert.
CRM_API_VERIFY_TLS=true
# Ten31 team-member names (comma-separated), fed to the parser so a teammate's name reads as
# the person DOING outreach, not the prospect ("Jonathan is chatting with Wyoming" → Wyoming).
# Optional; first names as actually used in the room. Leave empty to disable the framing.
INTAKE_TEAM_ROSTER=
+20 -14
View File
@@ -1,6 +1,6 @@
# Ten31 Venture CRM + Agentic System — AGENTS.md # Ten31 Venture CRM + Agentic System — AGENTS.md
**The foundation is a self-hosted venture-fund CRM** — a purpose-built fundraising tool that replaced Airtable to (1) keep sensitive LP/prospect data off third-party servers, (2) drop subscription cost, and (3) fit the fund's workflow: managing ~150 existing LPs, tracking 250+ prospects, and running the capital-raise pipeline. Core CRM domain: contacts (investor/prospect/advisor), organizations, opportunities (the deal pipeline), communications, and LP profiles. The fund (Ten31, ~$200M AUM, bitcoin/energy/AI thesis) runs it on a Start9 box, accessed on the LAN or over Tailscale by a team of ~5. Schema/API tour: `docs/crm-overview.md`. **The foundation is a self-hosted venture-fund CRM** — a purpose-built fundraising tool that replaced Airtable to (1) keep sensitive LP/prospect data off third-party servers, (2) drop subscription cost, and (3) fit the fund's workflow: managing ~150 existing LPs, tracking 250+ prospects, and running the capital-raise pipeline. Core CRM domain: contacts (investor/prospect/advisor), organizations, opportunities (the deal pipeline), and communications; investor commitments live in the canonical `fundraising_*` grid (the legacy single-fund `lp_profiles` table was retired in v0.1.0:78). The fund (Ten31, ~$200M AUM, bitcoin/energy/AI thesis) runs it on a Start9 box, accessed over ClearNet (StartOS StartTunnel) with app-level user auth by a team of ~5 (Tailscale is not in use). Schema/API tour: `docs/crm-overview.md`.
**The agentic system is new functionality built on top of that CRM** — an in-house AI layer to widen the fundraising funnel, sharpen the thesis, and automate outreach drafting. Frontier reasoning runs on Claude (Agent SDK/API); privacy-sensitive and bulk work runs on local DGX Spark models via the **Spark Control** gateway. **Phase 0/1 — no live outward-facing agents; agents draft, humans send.** **The agentic system is new functionality built on top of that CRM** — an in-house AI layer to widen the fundraising funnel, sharpen the thesis, and automate outreach drafting. Frontier reasoning runs on Claude (Agent SDK/API); privacy-sensitive and bulk work runs on local DGX Spark models via the **Spark Control** gateway. **Phase 0/1 — no live outward-facing agents; agents draft, humans send.**
@@ -22,7 +22,7 @@
```bash ```bash
# Run locally (dev, port 8080; or ./start.sh <port>) — runs python3 backend/server.py # Run locally (dev, port 8080; or ./start.sh <port>) — runs python3 backend/server.py
./start.sh ./start.sh
# Run prod-mode (Tailscale/beta) — requires CRM_SECRET_KEY # Run prod-mode (beta) — requires CRM_SECRET_KEY
./start_beta.sh ./start_beta.sh
# Sanity-check edits (there is no compiler/build for the CRM) # Sanity-check edits (there is no compiler/build for the CRM)
python3 -m py_compile backend/server.py python3 -m py_compile backend/server.py
@@ -39,7 +39,7 @@ cd start9/0.4 && make
## Directory layout (day-one) ## Directory layout (day-one)
- `backend/server.py` — the CRM monolith: HTTP handler, route dispatch, `init_db()`, auth (username/password → HS256 JWT, roles admin/member). - `backend/server.py` — the CRM monolith: HTTP handler, route dispatch, `init_db()`, auth (username/password → HS256 JWT, roles admin/member/bot).
- `backend/core_migrations.py` + `backend/migrations/NNNN_*.sql` (+ paired `.down.sql`) — additive schema migrations, applied at startup. - `backend/core_migrations.py` + `backend/migrations/NNNN_*.sql` (+ paired `.down.sql`) — additive schema migrations, applied at startup.
- `backend/thesis_seed.py` — Thesis Workshop seed + idempotent `ensure_*` one-time seeders, wired in `server.init_db()`. - `backend/thesis_seed.py` — Thesis Workshop seed + idempotent `ensure_*` one-time seeders, wired in `server.init_db()`.
- `backend/thesis_review.py` — thesis version review/approval (human dual sign-off → canonical). - `backend/thesis_review.py` — thesis version review/approval (human dual sign-off → canonical).
@@ -48,6 +48,8 @@ cd start9/0.4 && make
- `backend/redaction/``scrub.py` + `client.py`: the scrub→Claude→re-hydrate privacy boundary. - `backend/redaction/``scrub.py` + `client.py`: the scrub→Claude→re-hydrate privacy boundary.
- `backend/ingest/` — chunk→embed→Qdrant + retrieval modes. - `backend/ingest/` — chunk→embed→Qdrant + retrieval modes.
- `backend/entity_*.py` — entity resolution/merge (the two-investor-model reconciliation). - `backend/entity_*.py` — entity resolution/merge (the two-investor-model reconciliation).
- `backend/nl_query/` — read-only natural-language query (W2): `intents.py` (curated parameterized query catalog), `runner.py` (slot validator = trust boundary), `translate.py` (local-Qwen question→{intent,slots}). See the nl-query guide.
- `backend/matrix_intake/` — Matrix intake bot (separate process; `matrix-nio`, isolated to this component): typed message → local-Qwen parse → in-thread approve → write via the CRM's own `log-communication`. See the matrix-intake guide.
- `frontend/index.html` — the entire UI. - `frontend/index.html` — the entire UI.
- `docs/` — architecture, phase plans, contracts, runbooks (see Deeper docs). `docs/guides/` — scoped subsystem rules (see below). - `docs/` — architecture, phase plans, contracts, runbooks (see Deeper docs). `docs/guides/` — scoped subsystem rules (see below).
- `start9/0.4/` — StartOS package (`startos/utils.ts` holds `PACKAGE_VERSION`). - `start9/0.4/` — StartOS package (`startos/utils.ts` holds `PACKAGE_VERSION`).
@@ -63,13 +65,17 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
- **Ingest / retrieval** (`backend/ingest/`) → `docs/guides/spark-ingest.md` - **Ingest / retrieval** (`backend/ingest/`) → `docs/guides/spark-ingest.md`
- **Email capture / drafts + digest send** (`backend/email_integration/`, `backend/digest_mailer.py`, `backend/smtp_send.py`) → `docs/guides/email.md` - **Email capture / drafts + digest send** (`backend/email_integration/`, `backend/digest_mailer.py`, `backend/smtp_send.py`) → `docs/guides/email.md`
- **Building or deploying the s9pk** (`start9/`) → `docs/guides/packaging.md` - **Building or deploying the s9pk** (`start9/`) → `docs/guides/packaging.md`
- **Matrix intake bot** (`backend/matrix_intake/`) → `docs/guides/matrix-intake.md`
- **Natural-language query** (`backend/nl_query/`) → `docs/guides/nl-query.md`
## Conventions ## Conventions
- **Two coexisting investor models** (classic `contacts`/`lp_profiles` + the `fundraising_*` grid). Reconciling them to canonical IDs is the core entity-resolution task — see `docs/crm-overview.md`. - **Investor model — the grid is canonical (since v0.1.0:78).** The `fundraising_*` grid is the **system of record**: an investor entity (row) → many contact "pills" → per-fund commitments. The classic `contacts` table is a **read-only per-person directory**, auto-populated from the grid — create/edit people in the grid, not the Contacts page. Email capture rolls multiple people up to one investor. The legacy single-fund `lp_profiles` model is **retired** (empty table kept, per never-hard-delete). Reconciling grid ↔ classic `contacts` to canonical IDs is the core entity-resolution task — see `docs/crm-overview.md`.
- **Soft-delete only:** `deleted_at` and/or `status='retired'`; never hard-delete. Every READ path must filter `deleted_at IS NULL` — list handlers, get-by-id, nested related-data sub-selects, **and aggregate sub-selects (`COUNT`/`SUM`/`MAX`)**. Audits found leaks in all of these (2026-06-12 detail + nested; 2026-06-13 list-view `contact_count`/`total_funded`/`comm_count`); the **reports** subsystem aggregates still leak (see Current state). Regression-guarded by `backend/test_soft_delete_reads.py`. (Thesis has a subtlety here — see the thesis guide.) - **Soft-delete only:** `deleted_at` and/or `status='retired'`; never hard-delete. Every READ path must filter `deleted_at IS NULL` — list handlers, get-by-id, nested related-data sub-selects, **and aggregate sub-selects (`COUNT`/`SUM`/`MAX`)**. Audits found leaks in all of these (2026-06-12 detail + nested; 2026-06-13 list-view `contact_count`/`total_funded`/`comm_count`); the **opportunities/pipeline** aggregates were fixed in v0.1.0:87 (`handle_pipeline_report` + dashboard pipeline metrics now filter `deleted_at`), but the **reports** subsystem's **communications-side** aggregates (dashboard `recent_comms`/`comms_this_month`/`meetings_this_month`, activity report) still leak (see Current state). Regression-guarded by `backend/test_soft_delete_reads.py` (+ `test_reminders.py` for the reminders read paths, incl. the recency rollup whose email-activity liveness signal is `email_account_messages.deleted_at`, not `emails`). (Thesis has a subtlety here — see the thesis guide.)
- **Env:** secrets in `.env` (gitignored); names in `.env.example`. Verified names: `ANTHROPIC_API_KEY`, `SPARK_CONTROL_URL`, `SPARK_CONTROL_VERIFY_TLS`, `QDRANT_URL`, `X_API_KEY`, `CRM_DB_PATH`, `CRM_DEV_DB_PATH`. Also used: `CRM_SECRET_KEY` (beta/prod), `CRM_HOST`/`CRM_PORT`, `CRM_DATA_DIR`; digest mailer: `CRM_DIGEST_SENDER` (DWD impersonation sender) + `SMTP_HOST`/`SMTP_PORT`/`SMTP_SECURITY`/`SMTP_FROM`/`SMTP_USERNAME`/`SMTP_PASSWORD` (SMTP fallback); daily digest (Phase B): `CRM_DIGEST_ENABLED` + `CRM_DIGEST_SEND_HOUR` **only seed the first-boot default** — the live control is the DB policy (`app_settings.digest_policy`, set in Settings → Admin). - **Env:** secrets in `.env` (gitignored); names in `.env.example`. Verified names: `ANTHROPIC_API_KEY`, `SPARK_CONTROL_URL`, `SPARK_CONTROL_VERIFY_TLS`, `QDRANT_URL`, `X_API_KEY`, `CRM_DB_PATH`, `CRM_DEV_DB_PATH`. Also used: `CRM_SECRET_KEY` (beta/prod), `CRM_HOST`/`CRM_PORT`, `CRM_DATA_DIR`; digest mailer: `CRM_DIGEST_SENDER` (DWD impersonation sender) + `SMTP_HOST`/`SMTP_PORT`/`SMTP_SECURITY`/`SMTP_FROM`/`SMTP_USERNAME`/`SMTP_PASSWORD` (SMTP fallback); daily digest (Phase B): `CRM_DIGEST_ENABLED` + `CRM_DIGEST_SEND_HOUR` **only seed the first-boot default** — the live control is the DB policy (`app_settings.digest_policy`, set in Settings → Admin).
- **Config placement:** operational/feature toggles live in the **admin panel**, DB-backed via `app_settings` (read-merge through a `load_*_policy(conn)` helper shared by the API + any scheduler; precedence DB-row → env-seed → default), so they're discoverable and take effect live. Reserve StartOS actions / env for **secrets and deploy-time config** (SMTP creds, API keys, DWD sender). Precedent: `digest_policy` (`GET/PATCH /api/admin/digest/policy`), `fundraising_backup_policy`. - **Config placement:** operational/feature toggles live in the **admin panel**, DB-backed via `app_settings` (read-merge through a `load_*_policy(conn)` helper shared by the API + any scheduler; precedence DB-row → env-seed → default), so they're discoverable and take effect live. Reserve StartOS actions / env for **secrets and deploy-time config** (SMTP creds, API keys, DWD sender). Precedent: `digest_policy` (`GET/PATCH /api/admin/digest/policy`), `fundraising_backup_policy`.
- **Agent/bot API access — three roles now (`admin`/`member`/`bot`).** `require_admin` is the only hard gate; everything else is "authenticated" (member, admin, *and* bot all pass). The **`bot` role** (added v0.1.0:89) is authenticated-but-never-admin: `require_bot_or_admin` gates agent-facing endpoints (e.g. `/api/intake/email-proposals*`) so a bot credential reaches *only* what it needs, never user-management/settings/security. Provision it via Settings → Admin edit-user dropdown (kept out of the teammate-invite form). **Two axes to keep separate as more agent capability lands:** the role controls *reach* (which endpoints); the per-feature human draft→approve gate controls *autonomy* (acting unattended). Money/merge/delete mutations stay behind the approval gate regardless of role. Don't build a finer capability/scope system until real NL-mutation endpoints exist to scope against.
- **Design:** before building or changing any user-facing UI, read `design/DESIGN.md` and `design/tokens.tokens.json` and conform to them. A **mobile-first redesign** is in flight — read `design/BRIEF.md` before any responsive/layout work. (Note: inline `style={{}}` objects can't respond to media queries; responsive layout belongs in the CSS `<style>` block.)
- **Commit style:** imperative subject, concise body explaining the *why*; put the package version in the subject (`… (v0.1.0:NN)`) for shippable changes. **No AI co-author / attribution trailers** — commits are authored by the user. - **Commit style:** imperative subject, concise body explaining the *why*; put the package version in the subject (`… (v0.1.0:NN)`) for shippable changes. **No AI co-author / attribution trailers** — commits are authored by the user.
## Always ## Always
@@ -101,13 +107,13 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
## Current state ## Current state
_Phase 0 substrate + Phase 1 thesis/outreach are built; **box and repo at v0.1.0:77** (digest **Phase B** — daily activity-digest builder/scheduler + by-team-member & by-investor sections + admin-panel control + on-demand send; deployed & verified live 2026-06-16). Longer-term backlog: `ROADMAP.md`._ _Phase 0 + Phase 1 built; **box + repo live at v0.1.0:94**. **The fundraising grid + email capture is the canonical system of record.** Two active threads: **mobile-first redesign** (design phase, with Grant) and **W2 NL query** (live; web "Ask" box outstanding). History: git log + `start9/0.4/startos/versions/`; backlog/debt: `ROADMAP.md` / `EVALUATION.md`._
- **Working (all draft-only):** CRM + ingest (chunk→embed→Qdrant + retrieval) + redaction boundary; Gmail capture (DWD) + email-activity propose→approve; Thesis Workshop + Architect (Claude) with dual-approval gate; Outreach Draft Assistant + follow-up radar + per-user voice + Tier-B in-thread Gmail draft creation. - **Mobile-first redesign — design phase, in progress with Grant.** Design contract live (`design/DESIGN.md` + `tokens.tokens.json` + `BRIEF.md`). This session: sanity-checked the brief vs the real backend (added a "Backend reality" note to `BRIEF.md` §3a — the grid has **no field-level write** (one versioned JSON-blob PUT; single-investor writes should use the targeted `log-communication` path), and **pipeline stage is a separate 2-call flow**). **Locked the pipeline-stages/flags redesign** (full spec in `ROADMAP.md`): 4-stage funnel **Lead→Engaged→Diligence→Commitment**, auto-derived **Existing-Investor** flag (`total_invested>0`), **Priority+Graveyard** the only disposition flags (Longshot dropped), **staleness** as a last-contact recency overlay (grey→amber→red, one global threshold) + a **W1b** Matrix nudge (never auto-demote). A Claude Design cloud session is iterating screens; `BRIEF.md` §3a card model aligned to these.
- **Deployed & verified live: v0.1.0:77** (box `$START9_BOX_HOST`/immense-voyage.local; `installed-version``0.1.0:77`, migration chain `…→77`, server up on `:8080`, digest scheduler line in the boot log). **Digest is fully live:** capture (DWD) → propose→approve; transport routes Gmail-DWD→SMTP (no app password); and **daily activity digest (Phase B)**`digest_builder.py` (by-team-member Spark narrative + by-investor section, soft-delete filtered) + always-on `digest_scheduler.py` reading a DB policy + `send-now`. **Auto-send defaults OFF** (env seed unset → `app_settings.digest_policy` off) until Grant enables it in Settings → Admin. Detail: `docs/guides/email.md`. - **Built, deploy pending:** **drag-reorder grid views** (frontend-only; `moveViewBefore` in `index.html`; persists via the existing autosave → `views_json`; render-smoke green, browser-interaction untested). A one-off batch of 8 ideas captured in `ROADMAP.md`; the spark-control dashboard-card item → `standards/INBOX.md`.
- **Live since v74 (2026-06-13):** login works; `/assets/` traversal 404s (plain + URL-encoded), root health 200. On boot, `ensure_thesis_v2_promoted` makes the v2.0 reserve-asset spine the working *approved* spine (node-level, reversible). Security/privacy hardening (path-traversal close, outreach NER backstop, get-by-id soft-delete) shipped in v74 — detail in `EVALUATION.md`. - **W2 — NL query (read-only): LIVE** (v93; matched-only fix v94). Local-Qwen translate → curated parameterized intents + slot validator (trust boundary; no generic SQL), `POST /api/query/nl`, audited; Matrix Q&A room + intake `?`/`@bot` trigger live. Remaining: **in-room human smoke** + **step-4 web "Ask" box**. Guides: `docs/guides/nl-query.md` + matrix-intake.
- **Tests (2026-06-16):** **20/20 backend tests green** via `python3 backend/run_tests.py` (+`test_digest_builder.py` this session — per-user + per-investor queries, soft-delete, inbound dedup, two-section compose, fallback, DB policy resolver, scheduler guards). `py_compile` clean. The 2 stale thesis tests stay fixed (seed structure in `docs/guides/thesis.md`). - **W1 — reminders: LIVE (v93).** Tickler tied to the grid (migration `0006`, `/api/reminders`, derived `reminder_status`, `last_activity_at` rollup). Deferred **W1b** = nurture-gap auto-suggested reminders (the redesign's staleness nudge specializes it to Engaged/Diligence).
- **Decided, not yet built:** CRM as canonical thesis backbone with the signal-engine reading from it (reconciliation unwired); reply-all for Tier-B drafts (drafts currently reply to the LP only). - **Done & live:** email-proposal Matrix review + `bot` role (v91); grid-driven Pipeline (v88); Matrix intake bot; Gmail capture (DWD) + propose→approve + daily digest; Thesis Workshop + Architect (Claude, dual-approval); outreach drafts. All draft-only.
- **Known debt (P2, not deploy-blocking):** the **reports subsystem** (`handle_dashboard_report`/`handle_pipeline_report`/`handle_lp_breakdown_report`, ~16 aggregate queries over contacts/opportunities/communications/lp_profiles) still counts soft-deleted rows — the list/detail aggregates were fixed (v74 + the org/contacts list-view follow-up) but the reports were not; needs its own pass + report-endpoint tests; `?limit=abc` crashes the request thread (authenticated list path); scrub-gateway TLS verify off; `cryptography==42.0.5`; unpkg/no-SRI frontend; stale user-visible `start9/0.4/assets/ABOUT.md`; hardcoded Spark/Qdrant IPs in the s9pk; the 5.4k-line `server.py` monolith. P3 batch + full list in `EVALUATION.md`. - **Tests:** **35/35 backend green** (`python3 backend/run_tests.py`), `py_compile` clean; render-smoke gates `make`.
- **Other gaps:** the v2.0 spine is the *working* spine but **not a canonical `thesis_version`** (needs Grant + Jonathan dual sign-off); Appendix-A conviction/exposure (incl. ~40% Strike) stay Grant's working read, not canonical, not fed to the engine; live features (Claude/Qdrant/Gmail) unverified on the box. - **Next (priority order):** 1) mobile-first implementation once the design session lands — prerequisite is the **inline-style→CSS migration** (responsive can't live in ~1300 inline styles); 2) build the **locked pipeline-stages/flags spec** (one-time enum + data migration + derived flags/overlay); 3) **deploy** the view-reorder (next s9pk build); 4) **W2 step-4** web Ask box + in-room Q&A smoke; 5) **W3** bot grid-mutations behind the Matrix gate; 6) **W1b** nurture-gap reminders; then P2 debt (reports comms-aggregate soft-delete sweep, `?limit=abc` crash, auth regression test, oversized icon).
- **Next:** 1) Grant validates Phase B on the box — Settings→Admin **Send Digest Now** to check the real digest, then tick **Send automatically every day** + pick a time to arm the daily send (arming is in-app — no env/StartOS change); 2) **reports-subsystem soft-delete sweep** (~16 aggregates still leak; fix + tests); 3) `?limit=abc` crash (P2); 4) Grant + Jonathan freeze v2.0 canonical; 5) build reply-all; 6) confirm Appendix-A + Maple/OpenSecret/Primal, then promote. - **Open / risks:** mobile responsiveness is **blocked on the inline-style→CSS migration** (large, not yet scoped); W2 translation only **happy-path-validated**; **Claude/Architect path still unverified live on the box**; v2.0 reserve-asset spine approved but **not canonical** (needs dual sign-off); doc drift — `crm-overview.md` + `EVALUATION.md` still call `lp_profiles` live.
+162 -1
View File
@@ -86,8 +86,63 @@
## Backlog (post-Phase-1 agentic) ## Backlog (post-Phase-1 agentic)
### Follow-ups/reminders + NL search + bot grid-mutations (agreed plan, 2026-06-18)
*Agreed with Grant 2026-06-18. Three workstreams, sequenced **W1 → W2 → W3**. **Overarching constraint (Grant):** the dominant risk is **leaking LP data (names, $, notes, contacts) to third-party LLMs — NOT write-safety.** A wrong number is recoverable; investor substance reaching Claude is not. Consequences: W2 keeps LP rows off Claude (only the question text + schema vocabulary leave the box; entity names resolved locally); W3 keeps bot mutation-parsing on local Qwen. Because this DB *logs* commitments/pipeline but doesn't move money, a bot mutation is low-stakes → **any team member may approve one in Matrix**; the guardrail is "the bot can't silently mass-change numbers," enforced by the per-mutation human approval gate, not a tight money gate.*
**W1 — Reminders & follow-ups — BUILT + tested locally 2026-06-18 (v0.1.0:92, deploy pending).** First-class tickler tied to the grid: `reminders` table (in-app migration `0006`; logical FK to `fundraising_investors.id` + denormalized name, like `0005`), full CRUD (`GET/POST/PATCH/DELETE /api/reminders`; soft-delete; status open/done/snoozed/cancelled; assignee; `source` human/bot/automation), a read-only **derived `reminder_status` grid column** (overdue/due_soon/open — injected like `pipeline_stage`, **filterable so the follow-up view can later key off reminders instead of the binary `follow_up` checkbox**, per Grant), an orphan reconciler (`reconcile_grid_reminders` — cancels reminders when their investor leaves the grid, the pipeline reconciler's twin), a **Reminders** nav page (filter/complete/snooze/edit/delete + create), a Dashboard **"Reminders Due"** card, a **"Reminders due"** daily-digest section, and a per-investor **`last_activity_at`** recency rollup (the shared building block W2's "not nurtured" query needs). Pure local CRM — no LLM path, no leak surface. Tests: `test_reminders.py` + digest reminders test; **31/31 suite green, render-smoke green**. **Deploy:** needs an s9pk build + install (version bumped to 92); get authorization first.
- **W1b (deferred fast-follow):** nurture-gap automation — a daily job flags "committed / in-pipeline + no activity in N days + no open reminder" → auto-suggests a reminder (`source='automation'`, human confirms). Build once the recency rollup is proven in practice.
- **Left untouched (deliberate):** the grid `follow_up` checkbox + automation list-memberships, and `communications.next_action_date` + `/api/outreach/radar` — reminders are the new richer layer; folding those into it is a later cleanup, not now.
**W2 — Natural-language query (read-only). BACKEND BUILT + tested + validated locally 2026-06-18; web/Matrix UI pending.** = the **"Email/communication search + NL query → item 3 (NL→safe structured query)"** below, now sequenced second and **redesigned** (see below). Subsystem detail: `docs/guides/nl-query.md`.
- **Approach changed from the original "Claude behind redaction + a validated filter-AST" to LOCAL-ONLY + a named-intent catalog (decided with Grant 2026-06-18).** Rationale: (a) the dominant risk is LP data reaching a vendor — running translation on the **local Qwen via Spark Control** keeps the question on the box entirely (same basis as intake/digest), so there is **no Claude path and no redaction boundary** to manage, which is both simpler and safer; (b) a generic SQL/AST compiler was over-built for the real need — instead there are **~12 curated, hand-written, parameterized "named queries"** (`backend/nl_query/intents.py`) each with typed slots, and the **slot validator** (`runner.validate`) is the whole trust boundary (no dynamic identifiers, no raw SQL). The LLM only maps a question → `{intent, slots}`; its output is still validated, so a hallucinated intent is rejected. **Results never go to any model** (deterministic local render). Both design choices were pressure-tested by independent review agents before building.
- **As built:** `backend/nl_query/` (`intents.py` catalog, `runner.py` validator/executor + audit, `translate.py` local-Qwen translator, `try_questions.py` dev harness). `POST /api/query/nl` (`{question}` or direct `{intent,slots}`) + `GET /api/query/catalog`, `require_bot_or_admin`, read-only, audited (`audit_log` `entity_type='nl_query'`). Soft-delete-correct per table (`fundraising_*` has no `deleted_at``graveyard` is the axis; emails via a live `email_account_messages` sighting; reminders/opps/comms via `deleted_at`). Builds on W1's `last_activity_at`. Tests: `nl_query/test_nl_query.py` + `test_translate.py` + `test_nl_query_endpoint.py` (34/34 suite green).
- **Validation:** the local Qwen translated **12/12 of Grant's real example questions** correctly (right intent + slots, incl. "3 months"→90, sent/received→direction) against the live Spark — settles local-only; Claude not needed. Translation quality on messy/typo/no-match inputs shakes out in live use.
- **Remaining:** **step 4** = web "Ask" box in the Communications tab (calls the endpoint, renders rows + the interpreted query); **step 5** = Matrix `@bot <question>` (thin client of the endpoint; the 2-admin review room means a full-book dump is acceptable, so no bulk-result cap — only a light anti-flood truncation). Reads need no approval gate. Then deploy with reminders (v92) as **v0.1.0:93**.
**W3 — Bot grid-mutations behind a Matrix approval gate.** Generalize the email-proposal scaffold (`email_proposal_matrix` + propose→post→decide→apply) into one `agent_proposals` table (kind discriminator + JSON payload + target). Bot proposes set-commitment / assign-fund / change-stage / set-reminder; a human approves/edits/rejects in Matrix (**any member**); then apply. **Surgical, version-checked mutations — never blob RMW:** stage rides the existing `opportunities` link + validated stage endpoint; reminders write the W1 table; set-commitment/assign-fund need a version-checked single-cell upsert into the grid blob. Triggers the deferred **scoped service-token** item below (per-mutation-kind allowlist on the bot credential; money/merge/delete always require human approval regardless of scope — the autonomy axis). Parse on local Qwen, not Claude.
### Matrix-bridge intake for the fundraising grid — M1+M2 BUILT (deploy + live smoke pending)
*Requested 2026-06-16. **M1 (scaffold + parse + in-thread propose) and M2 (match + write-on-approve) built, tested (26/26), not yet deployed** — code in `backend/matrix_intake/`, guide at `docs/guides/matrix-intake.md`. Remaining: install `matrix-nio` + creds on the Spark, create the CRM bot user, and a **live Matrix smoke** (can't run in CI). M3 (business-card photo) deferred until Spark Control has a vision model. Next major build after this is **Pipeline adoption** (see below).*
Use the **matrix-bridge** repo's pattern to listen on a dedicated ten31-database Matrix room. Send a message (with an optional business-card photo) and a local LLM **via Spark Control** parses it into the fundraising-grid schema and **auto-creates the investor entity + contact row**. For an existing investor, send a meeting note and it **appends an interaction-log entry**. Approval gate: the bot replies in Matrix with the proposed add/edit; the user approves / rejects / edits in-thread before the write commits (keeps the draft→human-approve guardrail).
- Fits the "grid is canonical" direction (writes land in `fundraising_*`) and the never-send-autonomously rule (in-thread human approval before any write).
**Locked design (2026-06-16, approved) — build now, M1 then M2:**
- **Separate component, shared scaffold:** new `backend/matrix_intake/` (its own process; lifts matrix-bridge's connect/prime-then-listen/threaded-reply plumbing). `matrix-nio` is isolated to this component's `requirements.txt` — it never enters the stdlib CRM runtime. Keeps the CRM write credential + LP data out of the general-purpose matrix-bridge bot (blast-radius + data-sovereignty), and lets the two iterate independently. Runs on the Spark (placement settled against `standards/guides/placement.md` at deploy).
- **v1 = text-only.** Business-card photo deferred to M3 — Spark Control fronts chat/embeddings/rerank but **no vision model** today, so photo→fields isn't buildable end-to-end yet.
- **Parse:** local Qwen via Spark Control `/v1/chat/completions` (temp 0, JSON-only), reusing the existing Spark client pattern (`backend/redaction`/`backend/ingest`).
- **Approval handshake (the one stateful piece):** in-memory pending-proposal store keyed by Matrix thread root; user replies **yes / edit field=value / no** in-thread. Satisfies never-write-autonomously; exempt from "agents draft, humans send" (internal data entry, like the digest).
- **CRM-side:** `POST /api/intake/investor` (service-auth) creates a new investor+contact **through the existing grid-save path** (so relational sync + audit + backup-on-write happen as with a UI edit; bot never does whole-blob RMW) or appends a meeting note to the interaction log for an existing investor; `GET /api/intake/match?q=` fuzzy-matches via the existing entity-resolution/email-matcher. New investor needs no fund at intake.
- **Phases:** M1 = scaffold + parse + in-thread propose, **no writes** (proves Matrix↔Spark). M2 = intake endpoint + match + write-on-approve + tests. M3 (deferred) = business-card photo.
**Post-deploy enhancement — fuzzy match + in-thread confirm (Grant, 2026-06-17). DEPLOYED & LIVE 2026-06-17 (v0.1.0:86; box migration chain …85→86 clean, `candidates` endpoint verified); Matrix live-smoke pending.** Today `find_intake_match` is **exact-after-normalization** (`_normalize_text` = lowercase+strip), so near-misses — "Charlie" vs "Charles" (same last name), "Acme Capital" vs "Acme Capital LLC", a one-character email typo — return no match and the bot proposes a **new** investor, risking a duplicate the human approves without realizing a near-match exists. The existing in-thread approval gate is useless against this because the human is never *shown* the near-match. Fix: matcher returns **ranked fuzzy candidates** (deterministic pre-filter: normalized name similarity / token overlap + email edit-distance ≤ ~2), surfaced in-thread for the human to confirm or pick, with the **local Spark LLM optionally re-ranking/judging the shortlist** (good at Charlie/Charles + legal-suffix equivalence; fed only the shortlist, never the whole LP list). Keeps the approval gate but makes it effective against duplicates. Land **after** the live smoke — net-new logic + reply grammar + tests; the current exact match is safe and its failure mode (a duplicate) is recoverable via the existing entity-merge subsystem (`backend/entity_*.py`).
- **As built:** `find_intake_candidates` in `server.py` (deterministic — stdlib `difflib` name similarity + token-set Jaccard, legal-suffix-aware via `_strip_legal_suffix`, + email Levenshtein ≤ 2; ranked, ≥0.62, top 5). `GET /api/intake/match` now returns `{match, candidates}`. Bot: a new `_stage="disambiguate"` shortlist (`proposals.render_disambiguation` / `interpret_disambiguation` / `attach_to_candidate` / `promote_to_new`) — human picks a number / `new` / `no`. **The optional LLM-judge re-rank was deliberately deferred** (the deterministic filter already surfaces the named cases; an LLM judge is the right *pruner* for shortlist noise — build if the deterministic ranking proves too noisy in practice). Tests: `test_intake_endpoints.py` (server fuzzy cases), `matrix_intake/test_proposals.py` (disambiguation grammar), `matrix_intake/test_crm_client.py` (candidate shape).
**Post-deploy enhancement — conversational (LLM-mediated) edits (Grant, 2026-06-17). DEPLOYED & LIVE 2026-06-17 (bot-side; pulled + restarted on the Spark `modelo32`); Matrix live-smoke pending.** Today an in-thread correction uses a rigid grammar (`edit field=value`). Let a free-form reply that isn't `yes`/`no`/a literal `edit …` be treated as a natural-language revision instruction: send {current proposal + the instruction} back through local Qwen (`spark.py`, the same parse leg — no Claude, no scrub) and re-render the revised proposal card for approval (e.g. "add that we met on June 14" → updated Note). Keeps the draft→human-approve gate (the human still confirms the LLM's revision) and subsumes `edit field=value` as a deterministic fast path. Thread the instruction text into `normalize`'s source so the email-integrity rule still holds (a revised email must appear in the original message or the instruction). Pairs naturally with the fuzzy-match item above — build both as one conversational-UX pass after the smoke. (Parsing of free-form *intake* messages already works today via the Qwen parse leg; this item is specifically about the *edit/refine* turn.)
- **As built:** `parse.revise` + `_apply_revision` (offline-testable; the approval-stage `else` branch in `bot.py` routes any non-yes/no/edit reply here). `parse_message` now stashes `_source_text` so revise can re-check email integrity against {instruction + original}; the model's email field is never trusted. No-op revisions are caught via `proposals.same_fields` (re-prompt, not a false "Updated"). **Known v1 limit:** revise edits fields but does not re-run the matcher on a mid-thread firm rename. Tests: `matrix_intake/test_parse.py` (revise merge + email integrity + match-id preservation).
**Managed service — DONE (container) 2026-06-17; dashboard card deferred to a spark-control session.** The bot ran as a bare `nohup` process (silently died on a Spark reboot). Now it's a **docker-compose service** (`docker-compose.yml` at the repo root + `backend/matrix_intake/Dockerfile`; `restart: unless-stopped` → survives reboot; image bundles `backend/matrix_intake` + the stdlib `backend/ingest` Spark client; `.env` mounted read-only). Cutover done on the Spark (nohup stopped, container `matrix-intake` up + listening). **Still bare `docker`/SSH-managed** — a spark-control dashboard card (Update/Restart/Stop/Logs tile like `matrix-bridge`) is a separate task in the spark-control repo: see `docs/handoffs/add-intake-bot-to-spark-control.md`.
**Parse mis-identifies the investor when the message names an internal teammate (found in live-smoke, 2026-06-17).** *"jonathan is chatting with wyoming soon about fund commitment"* → the bot picked **jonathan** (a colleague/CRM admin) as the investor and offered a Jonathan/Nathan fuzzy shortlist, when the investor is **Wyoming**. Root cause is upstream of matching: local Qwen has no notion of who's internal, and mis-read the sentence role. **Fix (cheap, high-confidence, near-term):** feed the parse prompt the ~5-person team roster + the frame *"messages are written by a team member about a prospect; a named team member is the person doing outreach, never the investor"* (roster from a config value or a small read — not the admin-gated `/api/users`, since the bot is a member). Offline-testable (stub the model). **Bigger design (deferred, needs more failure samples):** the user's idea of routing inputs through the LLM *with grid context* for entity resolution — feasible (local Qwen, same as the digest, never Claude) but feed a **bounded shortlist, not the full ~400-name grid** (a small model dilutes on a haystack); pairs with the deferred LLM-judge. Also exposes a missing concept: the **internal deal owner** (Jonathan), which the bot doesn't model. Get 35 more real intake messages before re-architecting; the roster fix lands regardless.
**Long-term — extract the intake bot to its own repo (recommended, not yet done).** Containerizing from this monorepo is the pragmatic now-state, but the bot is a genuinely separate deployable (own process, own `matrix-nio` dep, own lifecycle); its only CRM coupling is the HTTP API (a clean network contract) plus ~40 lines of stdlib Spark client (cheap to vendor). The tell: the spark-control Update button would run `git reset --hard origin/main` on the **whole CRM clone** — wrong blast radius. `matrix-bridge` is already a dedicated repo; mirror it. The extraction is a migration (new Gitea repo, move code + tests + guide, vendor the client, re-point the Spark deploy), so it's deferred until worth the lift — do it *before* wiring the spark-control card if both land in the same push.
### Scoped service-credential auth path for automated CRM writers
*Surfaced 2026-06-17 while deploying the Matrix intake bot. **Decision: defer — the bot uses a dedicated member username/password for now.** The CRM has no API-key/service-token path; its only auth is username+password → JWT. A dedicated **member** login is appropriately scoped against what matters operationally (no admin: can't manage users, reset data, or change settings) and unblocks the live smoke today.*
**Accepted residual risk (why this is worth revisiting):** a member credential is far broader than the bot's actual need (two endpoints: `GET /api/intake/match`, `POST /api/fundraising/log-communication`). A member can **read the entire LP/prospect database** — the exact data this system exists to keep off third-party servers — plus broad member-level *write* within the fundraising domain (could create/append on any investor). The credential lives in a `.env` on the Spark, so a Spark compromise leaks read-access to all LP data. Mitigating context: own-infra, LAN-local; the Matrix bot is the **first out-of-process API writer** (the digest runs in-process with direct DB access), so there is exactly **one** consumer today → building a token-scope framework now is premature (YAGNI).
**Right long-term design:** a hashed, revocable **service token** with a per-route **scope allowlist** (intake-match + log-communication only), minted/revoked from the admin panel, replacing the bot's member login. Revocation then kills the token without rotating a reused human password.
**Build trigger:** when a **second** out-of-process automated writer appears, OR before **any** automated writer is reachable beyond the LAN — whichever comes first. Build it once, properly, at that point.
### Admin-only vs. all-users web-UI surface — audit
*Requested 2026-06-16 (idea, P2).* Have the **explorer agent** report which web-UI functionality is visible only to admins vs. to all users (member role) — a map of the role-gated surface across `frontend/index.html` and the backend route auth checks. Useful input for the consolidation/permissions work.
### Daily activity digest (email to the team) ### Daily activity digest (email to the team)
*Requested 2026-06-15. **Phase A deployed** (v0.1.0:76). **Phase B deployed & verified live in v0.1.0:77 (2026-06-16)** — digest content + Spark summarization + daily scheduler + by-investor section + admin-panel control + on-demand send. Auto-send defaults OFF until an admin enables it in Settings → Admin.* *Requested 2026-06-15. **Phase A deployed** (v0.1.0:76). **Phase B deployed & verified live in v0.1.0:77 (2026-06-16)** — digest content + Spark summarization + daily scheduler + by-investor section + admin-panel control + on-demand send. Auto-send defaults OFF until an admin enables it in Settings → Admin. **v0.1.0:83 (built, deploy pending): in-app windowed preview** — Settings → Admin builds a digest over a chosen window (last 24h or since a date) and shows it before sending (`POST /api/admin/digest/preview`), so the **real Spark summarizer can be verified on demand** even on a quiet day (the fixed last-24h `send-now` couldn't); manual send uses the same window and never touches the daily cursor.*
**Decisions (locked 2026-06-15):** recipients = **all active admins**; summarization = **Spark-LLM narrative** (never Claude — un-anonymized substance stays local); granularity = **grouped by user** (→ per investor). **Decisions (locked 2026-06-15):** recipients = **all active admins**; summarization = **Spark-LLM narrative** (never Claude — un-anonymized substance stays local); granularity = **grouped by user** (→ per investor).
@@ -108,6 +163,112 @@ Have the CRM send a **daily digest email** summarizing each registered user's ac
Open design questions (settled at build time): send time = **6 PM box-local** (configurable in the admin panel), covering the ~24h window up to send; empty days = **always send** with a "no activity" note; summary granularity = **one per-user narrative** plus a **by-investor structured section** (inbound + outbound, team-wide) added 2026-06-16; enable/time live in the **admin panel** (DB-backed), not StartOS actions. Open design questions (settled at build time): send time = **6 PM box-local** (configurable in the admin panel), covering the ~24h window up to send; empty days = **always send** with a "no activity" note; summary granularity = **one per-user narrative** plus a **by-investor structured section** (inbound + outbound, team-wide) added 2026-06-16; enable/time live in the **admin panel** (DB-backed), not StartOS actions.
### Email/communication search + natural-language query
*Requested 2026-06-16. Three increments, **sequenced 1 → 2 → 3** (1 and 2 first as a quick increment; 3 is a separate, larger build after). Origin: Grant asked whether we can query "emails sent to a specific investor" / "activity by user," and floated NL queries like "existing investors who have committed capital across our funds that we haven't emailed in a while."*
**Status: items 1 & 2 SHIPPED in v0.1.0:83 (built + verified locally 2026-06-16, deploy pending).** The Communications tab now has the structured activity surface (item 1: typed/fixed investor dropdown, mailbox + direction + **date-range** filters, free-text, **click-to-expand full body** via `GET /api/email/detail`) and a **"Search content"** semantic mode (item 2: `GET /api/email/search` over the Qdrant email index). The dropdown-empty bug (the facet only listed grid investors) was the v83 fix — it now mirrors the list across grid/org/contact matches. **Item 3 (NL→SQL) remains** — the larger, separate build below. Detail: `docs/guides/email.md`.
**Context — the data is captured but currently has NO front-end.** The entire Gmail email schema (`emails`, `email_threads`, `email_investor_links`, `email_account_messages`, `email_activity_proposals`, …) exists and is populated by the DWD capture pipeline, but is surfaced **nowhere** in `frontend/index.html` today (only as inputs to the daily digest). So all three items below are about making already-captured data queryable/visible. Email bodies of *matched* emails are already chunked + embedded into Qdrant with `{lp_id, lp_name, doc_type:"email", date_ts}` metadata.
**Caveat that shapes all three — the two-model join.** "Emails to an investor" link to the **fundraising grid** (`email_investor_links.fundraising_investor_id`); "committed capital" lives in the grid too (`fundraising_commitments`, multi-fund). But manually-logged `communications` and `lp_profiles` (single-fund) live in the **classic** model, and the two models are only bridged by fuzzy email/name matching (no authoritative join key). Any query spanning "committed capital" + "email recency" must reckon with this. Prefer the grid side as the higher-signal source (matcher already does).
**1. Activity query endpoints + panel — DONE (v0.1.0:83).** Delivered as the **Communications tab** rather than the originally-sketched `/api/activity` endpoints: `GET /api/email/activity` (`db.query_email_activity`) returns the actual records filterable by investor / mailbox / direction / **date range** / free-text, and `GET /api/email/detail` expands the full body. Answers "emails to investor X" and "what has mailbox Y sent" interactively. Soft-delete filtered throughout; investor identity is typed (`fund:`/`org:`/`contact:`) so org/contact-only matches resolve and are pickable. *(The `collect_user_activity()`/`collect_investor_activity()` digest helpers remain the by-user/by-investor pivot source; a dedicated per-user pivot UI was not needed for the answer Grant wanted, which the mailbox+direction filters already give.)*
**2. Email content search box — DONE (v0.1.0:83).** A **"Search content"** toggle in the Communications tab → `GET /api/email/search?q=` wraps `backend/ingest/search.py:hybrid_search` filtered to `doc_type='email'`; hits are hydrated + soft-delete-filtered against SQLite (canonical) and link back to the full body. Semantic/lexical search over email *content* ("find where we discussed the mining deal"), distinct from item 1's structured filters. 503 (clean "unavailable") when Spark/Qdrant is unreachable.
**3. Natural-language → safe structured query — SUPERSEDED & BUILT as W2 above (2026-06-18).** The design constraints below (especially "LLM = Claude behind the redaction boundary" and the validated-AST shape) were **revisited and changed** during the build: translation runs on the **local Qwen** (no Claude, no redaction), and the safe surface is a **named-intent catalog**, not a generic query AST. See the W2 entry above and `docs/guides/nl-query.md` for what shipped; the original framing is kept here for provenance. _An LLM translates a plain-English question into a **safe, read-only** DB query against the CRM, for relational/analytical questions that semantic search *cannot* answer — Grant's example ("committed across funds AND not emailed in a while") is joins + aggregates + recency, not a text-topic match. Original design constraints (locked at request time):_
- **LLM = Claude behind the redaction boundary** (better at text-to-SQL than local Qwen; the scrub→Claude→re-hydrate path already exists for the PII concern). Not Spark — Spark Control offers embeddings/rerank/RAG + local chat, but **no text-to-SQL**.
- **Safety is the hard part, not the parsing.** Do NOT hand the LLM open-ended SQL against the live DB (soft-delete leaks, injection, runaway scans). Constrain it: read-only connection/view, a curated/parameterized query surface or a validated query AST, soft-delete-filtered views, row/time caps. Treat as its own designed feature with its own tests.
- Must reckon with the two-model join caveat above (capital lives in the grid; recency from email links).
### Consolidate on the fundraising grid as canonical; retire vestigial classic-CRM surfaces
*Decided 2026-06-16. The CRM carries two stacked models: the original generic CRM (contacts / lp_profiles / opportunities / manual communications) and the fundraising grid + email capture. The team uses the grid; most classic surfaces are un-adopted (verified on the box: Pipeline + Communications empty, Contacts auto-populated from the grid). **Decision: the fundraising grid + email capture is the canonical system of record;** prune or repurpose the rest rather than maintain a parallel half-empty CRM.*
**Retire `lp_profiles` + LP Tracker — DONE & deployed live (v0.1.0:78, 2026-06-16).** 21/21 backend tests green, `py_compile` clean; installed to the box (`installed-version``0.1.0:78`, migration chain …77→78 clean, server up on :8080).
- Removed the orphaned `LPTrackerPage` component + the `lp-tracker``fundraising-grid` redirect (frontend).
- Removed the `/api/lp-profiles*` endpoints (list/get/create/update) and their handlers, the unused `lp-breakdown` report + route, the contact-dossier LP display (frontend + the `lp_profile` block in `handle_get_contact`), and the demo-seed LP block.
- **Dashboard KPIs repointed:** "Total Committed" now sums `fundraising_investors.total_invested` (the canonical grid rollup), **excluding graveyarded investors** so the headline reflects live committed capital — a deliberate divergence from `/api/fundraising/relational-summary`, which sums all rows. "Total Funded" dropped — the grid has no funded-vs-committed concept and the frontend never rendered it. (If a funded/wired status is wanted later, that's a new grid feature, not a revival of lp_profiles.) Regression-guarded by `test_dashboard_report.py`.
- **Left in place (intentional):** the empty `lp_profiles` table + index (no destructive drop, per never-hard-delete); the contact-delete soft-delete cascade; the `--reset-all-data` clear; and the inert MOCK_MODE `mockDb.lp_profiles` fixtures (dev-only fallback, never hits the backend — its dashboard mock still reads mock lp_profiles, a known dev-only divergence from the real backend). Updated `test_soft_delete_reads.py` to drop the now-removed `lp_profile` assertions (kept its org `total_funded` opportunities-aggregate checks).
**Adopt the Pipeline — wire it to the grid. — DONE: DEPLOYED & live-smoked 2026-06-18 (v0.1.0:88; migration chain …86→88 clean, `0005_grid_pipeline_link.sql` applied on the box, server up; the full +Pipeline → board → advance-stage → remove round-trip is verified on the box).** *(Was: second build after the Matrix-bridge intake.)*
- Pipeline (`opportunities`) is fully built and functional but unused. Keep it: it's the one classic surface that tracks something the grid doesn't — a forward-looking deal funnel (stage, `expected_amount × probability`, owner, close date) vs. the grid's actual committed dollars + flags.
- New idea (Grant, 2026-06-16): let users **flag an investor in the grid as a pipeline opportunity** (a grid column/control) so it **auto-creates / syncs an `opportunities` row** that loads into the Pipeline board. Design the grid↔pipeline link (which fund seeds it? what sets stage/expected amount? keep them reconciled). Turns Pipeline from a disconnected second data-entry surface into a view driven by the canonical grid.
- Revisit the stray contact-create side-door (the "Create Opportunity" modal `POST /api/contacts`) once the grid-driven flow exists.
**As built (decisions locked with Grant 2026-06-17):** UX = **row action + seed modal** ("Add to Pipeline" per grid row → captures primary contact / target fund / expected amount / stage / probability). The durable link is `opportunities.fundraising_investor_id` (**migration 0005**, additive + reversible); "is in pipeline?" / "what stage?" are **derived from a live opp join**, never a denormalized flag (no drift). **Ownership split:** the grid owns whether the link exists + the seed; the **board owns stage/probability/owner/close/next-step** — a grid save never reseeds a live opp (`POST /api/fundraising/pipeline/link` is idempotent: one live opp/investor, re-link returns the existing one). Contact is **reused from the grid's synced `fundraising_contacts.contact_id`** — the `POST /api/contacts` side-door is **gone**. Grid `lead` → opp owner (fallback acting user). Two **read-only** grid columns (Pipeline action + Pipeline Stage) injected on read; their row values are stripped on write so they never persist or dirty the autosave. **Remove from pipeline** (`POST .../unlink`) **soft-deletes the opp; the grid row is left fully intact** (Grant's explicit ask). Deleting an investor from the grid archives its orphaned opp (`reconcile_grid_pipeline_links`, called after `sync_fundraising_relational`). **Folded in:** the P2 soft-delete leak in `handle_pipeline_report` + dashboard pipeline aggregates (archived opps no longer counted). Tests: `backend/test_grid_pipeline_link.py` (link/idempotent/round-trip/guards/unlink-intact/re-link/orphan/aggregates), 28/28 suite green, render-smoke green. **Deploy:** server-side → needs an **s9pk build + install** (v87); get authorization first.
- **Follow-up (v0.1.0:88, frontend-only, DEPLOYED & verified 2026-06-18):** retired the Pipeline page's **"+ New Opportunity"** button + its create-by-contact modal — an opportunity is now born **only** from a fundraising-grid investor row ("+ Pipeline"), matching how the team works (they live in the grid). The board is now a view + stage-management surface; button replaced with a muted "Add deals from the Fundraising Grid" hint. Removed the dead handler/state + the page's unused `/api/contacts` fetch.
- **Deferred (not built):** no write-back of committed dollars into grid fund cells (grid stays canonical for committed $); a graveyarded investor with a live opp still shows its stage (deliberate — a live deal is a live deal).
**Keep the Contacts table — as the read-only per-person directory it already is.** Confirmed 2026-06-16: the grid models **investor entity → many people** correctly today. The grid "contacts" column is a multi-pill editor; each pill syncs to a `fundraising_contacts` row AND its own classic `contacts` row (5-person family office → 1 investor + 5 contacts, linked via `fundraising_contacts.contact_id`, migration 0004). The Contacts page is **read-only for creation** (header: "added from the Fundraising Grid"; no New-Contact button), edit-only via the detail slide-over — the desired flow already holds. Email capture already rolls **multiple people up to one investor** (matcher indexes each pill's email separately, all → same `fundraising_investor_id`; `email_investor_links` records both investor and specific person). No build here — future email-surfacing UI should present comms grouped by investor across all its people.
### Front-end: pre-compile JSX, drop runtime Babel (optional, larger)
*Logged 2026-06-16 during the v0.1.0:82 vendor+SRI work. The scoped fix shipped: React/ReactDOM/Babel are now vendored + SRI-pinned and served same-origin, and a jsdom render smoke check gates every build (`docs/guides/packaging.md`). This is the bigger alternative we deliberately deferred.*
Today the app ships `@babel/standalone` (~3 MB) and transforms ~5k lines of inline JSX **in the browser on every page load**. A build step that pre-compiles the JSX to plain JS would (a) eliminate the runtime-transform blank-screen class entirely (no Babel in production), and (b) load much faster. **Cost:** it introduces a build step, which contradicts the current **"No build step"** convention (single `frontend/index.html`, inline-Babel React) — so this is a real architecture change, not a tweak. Weigh only if page-load size/latency or render robustness becomes a felt problem; the render-smoke gate already de-risks the status quo. If taken: keep the source `index.html` editable, emit a compiled artifact into the s9pk, and keep the smoke check pointed at the built output.
### One-off feature batch (Grant, 2026-06-18)
*Eight one-off ideas, triaged against the backend 2026-06-18. **Cross-cutting guardrail:** anything framed as "auto-add / auto-forward / auto-suggest" lands as a **proposal surfaced for human approval** (reuse the `email_proposal_matrix` propose→Matrix→decide rails), never a silent write — per "agents draft, humans approve." #1 is built (deploy pending); #6 is a spark-control task (→ INBOX); the rest are scoped backlog. #2/#4/#7 reuse existing rails (email-proposal loop + W2 NL-query) — they're "wire a new source into an existing pipeline," not greenfield.*
- **1. Drag-reorder fundraising grid views — BUILT (frontend; deploy pending), 2026-06-18.** The sidebar view list is now drag-reorderable (HTML5 DnD mirroring the column-reorder idiom: `moveViewBefore` + `draggingViewId`/`dragOverViewId` in `frontend/index.html`). Order persists via the grid page's **existing autosave** (`views` is already in its snapshot + deps → `PUT /api/fundraising/state``views_json`), the same path rename/delete use — **no backend change.** Render-smoke green; the in-app drag interaction itself not yet browser-tested. **Known edge (same as existing rename/delete):** reordering while *off* the grid page only updates localStorage and is re-hydrated from the backend on next grid mount — reorder while viewing the grid. **Deploy:** needs an s9pk build + install.
- **2. [P2] Suggest new contacts from digested emails (outreach detector).** When a captured *outbound* email goes to an address not already in `contacts`/the grid and looks like outreach, propose adding it as a contact. Hangs off the existing email capture + `email_proposal_matrix` / `/api/intake/email-proposals` review rails — net-new is the detector + "looks like outreach" criteria (exclude vendors / newsletters / internal domains). Lands as a **proposal**, not an auto-add.
- **3. Pipeline stages + investor flags/labels — sharpened into a LOCKED SPEC (2026-06-19).** Was "new pipeline stages"; the design conversation collapsed it into a 4-stage per-investor funnel + auto-derived Existing-Investor flag + staleness overlay/nudge. **Full locked spec: see "Pipeline stages + investor flags/labels — LOCKED SPEC" below.**
- **4. [P2] Squarespace website form-submissions → DB (near-term, high value).** Parse `form-submission@squarespace.info` capture emails — structured Name / Email / Company / LinkedIn / Location / comments (see the website-lead screenshots, Grant 2026-06-18) — and feed them into the proposal flow. Deterministic parser (fixed format) + existing proposal rails = relatively contained. **Guardrail:** despite the "auto-added" ask, land each lead as a **Matrix proposal → one-tap approve**, not a silent insert (same pattern as email proposals). Real leads (e.g. Matt Baas, Vikrum Tatla) are currently only living in an inbox.
- **5. [P3] Matrix voice note → Spark Control transcription → intake.** matrix-nio receives an audio/voice event → download + decrypt the media → **Spark Control** transcription endpoint (Whisper-class — **confirm it exists; external dep**) → feed the text into the existing local-Qwen intake parse + disambiguation. Never call a Spark directly (Spark Control only). Larger; gated on the transcription endpoint existing.
- **6. → INBOX (spark-control repo, not this one). Dashboard card for the crm/intake bot** (Update/Restart/Stop/Logs tile like `matrix-bridge`). Already noted under the Matrix-intake "Managed service" item + `docs/handoffs/add-intake-bot-to-spark-control.md`; captured to `standards/INBOX.md` to confirm/do in a spark-control session.
- **7. [P2] Intake: "query the LLM when the name doesn't match."** Extend the disambiguation grammar (today: number / new / no — see screenshot) with a `search: <text>` option that runs the read-only **W2 NL-query** to locate the real existing investor when the typed name doesn't fuzzy-match a candidate. Builds on the existing NL-query + intake rails; keeps the human approval gate.
- **8. [P2] Email capture learns from approve/reject (scope down to rules v1).** Use the already-logged approve/reject decisions to pre-suggest a decision. **v1 = deterministic, not ML:** detect `List-Unsubscribe` / `Precedence: bulk` (newsletters) + a learned denylist of rejected sender addresses/domains → pre-mark / auto-suggest reject (e.g. recurring non-investor newsletters). Don't build a classifier until the rules prove insufficient.
### Pipeline stages + investor flags/labels — LOCKED SPEC (Grant, 2026-06-19)
*Sharpened from the inherited 6-stage funnel (lead/outreach/meeting/due_diligence/committed/funded) over a design conversation 2026-06-18/19. Supersedes one-off batch item #3. **Locked — ready to build on green-light.** Grounding (verified): the grid's only labeling today is 3 boolean flags (priority/follow_up/graveyard) + a derived longshot + the `lead` **owner** column; there is **no investor type field**; "existing investor" is implicit in `total_invested > 0`; the 6-stage pipeline lives on classic `opportunities` and only applies to rows explicitly "+Add to Pipeline"'d; saved views are driven off the flags, not stage.*
**Conceptual frame — three orthogonal axes (were conflated):**
- **A. Relationship** — existing-LP vs prospect → collapsed to a single **auto-derived "Existing Investor" flag** (below). No prospect/lead/advisor sub-types: leads become prospects fast, and there are no advisors in this grid.
- **B. Disposition flags** — keep **Priority** (the focus set) + **Graveyard** (truly dead). **Drop Longshot** — labeling something longshot is already half-giving-up, overlaps graveyard, and doesn't earn a third bucket. Everything not Priority/Graveyard is the neutral middle.
- **C. Pipeline stage** — the active-raise funnel (below), per-investor.
**1. Funnel = 4 stages, per-investor, terminal at Commitment:** `Lead → Engaged → Diligence → Commitment`
- **Lead** — identified + first contact (cold outreach, a logged first meeting, or a website inbound); one-directional so far.
- **Engaged** — a **two-way** conversation exists (they replied / there's a back-and-forth). *(Boundary confirmed with Grant: two-way, not "a second person at the firm.")*
- **Diligence** — substantive: follow-up calls/meetings or data-room access.
- **Commitment** — terminal. On commit → hand off to fund admin + record the $ in the grid fund cell; the pipeline's job is done.
- **No Funded** (fund admin owns post-commitment; the Existing-Investor flag is effectively the "closed" signal). **No Meeting** (an activity, not a position). **No Lost** stage (the Graveyard flag covers dead).
- **Start at any stage** — a known LP re-solicited for a new fund drops straight into Engaged/Diligence, not Lead.
**2. "Existing Investor" = auto-derived flag** from `total_invested > 0`, injected read-only like `pipeline_stage` (never a maintained column); rendered as a star/indicator (esp. mobile). Orthogonal to stage — a re-solicited LP shows the star **and** a live stage at once. Lifecycle: prospect runs Lead→…→Commitment → $ recorded in the grid cell → they light up as an Existing Investor.
**3. Staleness — a derived overlay on the stage + a Matrix nudge, NEVER an auto-demotion.** Governing principle: **derive-and-display freely; mutate state only via a human.**
- A quiet deal does **not** change stage. Staleness shows on the **last-contact recency value** (the grid row's / mobile card's "2d ago"): light-grey when fresh → **amber → red** by days since `last_activity_at`, appending "stale" once it crosses the threshold (e.g. "35d stale"). The stage chip stays clean; the warning rides the recency line. The **same `last_activity_at` source drives the desktop grid and the mobile card**, so both color-code automatically.
- **Why not auto-flip off Engaged/Diligence:** it re-couples axes B+C, silently destroys information ("stalled mid-diligence" vs "never engaged"), is a silent un-approved mutation (against the human-in-the-loop guardrail), and creates a perverse "log junk to stay alive" incentive.
- The "auto" part is the **nudge = W1b nurture-gap** (see the W1/W2/W3 backlog; this refines its target set to **Engaged/Diligence**, not Commitment): daily job flags "in pipeline (Engaged/Diligence) + no activity > threshold + no open reminder" → bot **suggests a reminder**, a human confirms → re-engage (logging a comm resets `last_activity_at`) **or** consciously graveyard. The system nudges; the human acts. Deals never silently fall off.
- **Stale threshold: ONE global threshold (locked 2026-06-19).** Not stage-aware for v1 (Diligence-trips-faster was considered and deferred). Pick the amber/red day-counts at build.
- **Stale-as-a-view:** also a saved grid view keyed on `last_activity_at` (e.g. >90d, not graveyarded) — distinct from the per-stage overlay; both reuse `last_activity_at`, no new field.
**Accepted tradeoff (per-investor, not per-fund — Grant's call):** re-soliciting an existing LP for a new fund reuses their single opportunity (set fund + reset stage) — you won't see "Funded Fund I / Diligence Fund III" as two simultaneous pipeline entries. The grid's per-fund $ columns remain the record of which funds an investor is in; the pipeline shows only the *current* raise. (Per-fund stage was considered and deferred as a bigger build.)
**Concrete change set (cost asymmetry: labels/overlays are cheap; the enum is the one-time expensive bit):**
1. **Enum:** `PIPELINE_STAGES = ['lead','engaged','diligence','commitment']` (`server.py:1833`) + the ~8 mirror sites: report ordering CASEs (`server.py:3782/3859`), `nl_query/intents.py:34/37`, frontend kanban (`index.html:4168`, mock `:2174`), opp-form `<option>`s (`:7732`), and the `'funded'/'lost'` filters in `total_funded`/`pipeline_value` (`server.py:2721/3766/3877`).
2. **Data migration** of existing `opportunities.stage`: `outreach,meeting→engaged`; `due_diligence→diligence`; `committed,funded→commitment`. Reconcile the stray `lost` value (not in the settable enum) to graveyard-flag semantics.
3. **Existing-Investor flag:** derive from `total_invested > 0`, injected read-only (grid column + mobile star).
4. **Drop Longshot:** remove the derived `longshot_followup` + its deprecated view filter.
5. **Staleness overlay:** green/amber/red on the injected `pipeline_stage` by `last_activity_at`, + the stale saved view.
6. **Nudge:** specialize **W1b** to Engaged/Diligence in-pipeline deals.
Items 36 are cheap (derived/read-time/frontend, reuse `last_activity_at`, no migration); items 12 are the deliberate one-time enum + migration.
**Card presentation (mobile + grid, locked 2026-06-19):**
- **Stage chip** = one of the 4 stages, shown **only when the row is in the pipeline** (most grid rows aren't — no chip / a faint "+ Pipeline" affordance otherwise).
- **Top-right corner** = the **Priority** disposition only (star/pill when flagged, empty otherwise). Graveyard rows live in the Graveyard view / render muted — not a corner badge.
- **Existing Investor** (auto-derived, `total_invested > 0`) = its own distinct indicator (star by the name or a left accent — **not** a per-card banner; keep it restrained per `design/DESIGN.md`).
- **Last-contact recency** carries the staleness color (grey→amber→red, "Nd stale").
- This **replaces the design-mockup's INVESTOR/PROSPECT category chip** — we have no prospect/investor *type*; that two-value badge was the tool deriving committed-$>0, which is exactly our Existing-Investor flag. Feeds `design/BRIEF.md` §3a.
## Definition of done for "Airtable substitute" v1 ## Definition of done for "Airtable substitute" v1
- Team can manage all investors in one master table - Team can manage all investors in one master table
- Saved views replicate current Airtable workflows - Saved views replicate current Airtable workflows
+155 -41
View File
@@ -21,7 +21,7 @@ importable (and testable with an injected chat fn) without Spark configured.
import json import json
import os import os
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
# One row per (account-sighting x investor-link) in the window. Grouped into # One row per (account-sighting x investor-link) in the window. Grouped into
@@ -75,6 +75,24 @@ _SYSTEM = (
"greeting, no bullet points, no sign-off." "greeting, no bullet points, no sign-off."
) )
# Reminders due is a current-state addendum (what needs action now), NOT bound to the
# email-activity window — a 6 PM digest should surface what's overdue / due today.
# status='open' only: a 'snoozed' reminder is an explicit mute, so it stays out of the
# digest by design (the quick-snooze UI keeps a reminder 'open' with a pushed-out date).
_REMINDERS_SQL = """
SELECT r.title AS title,
r.due_date AS due_date,
r.investor_name AS investor_name,
COALESCE(NULLIF(TRIM(u.full_name), ''), u.username) AS assignee
FROM reminders r
LEFT JOIN users u ON u.id = r.assignee_id
WHERE r.deleted_at IS NULL
AND r.status = 'open'
AND r.due_date IS NOT NULL AND TRIM(r.due_date) != ''
AND substr(r.due_date, 1, 10) <= ?
ORDER BY r.due_date ASC
"""
# ------------------------------------------------------------------ collection # ------------------------------------------------------------------ collection
@@ -180,6 +198,27 @@ def collect_investor_activity(conn, since_iso, until_iso):
return out return out
def collect_due_reminders(conn, today_iso):
"""Open reminders due on or before `today_iso` (overdue + due today), soft-delete
filtered. Returns [{title, due_date, investor_name, assignee, overdue}] sorted soonest
first. Empty if the reminders table is absent (feature not migrated on this box)."""
try:
rows = conn.execute(_REMINDERS_SQL, (today_iso,)).fetchall()
except sqlite3.OperationalError:
return []
out = []
for r in rows:
due = str(r["due_date"] or "")[:10]
out.append({
"title": (r["title"] or "").strip(),
"due_date": due,
"investor_name": (r["investor_name"] or "").strip(),
"assignee": (r["assignee"] or "").strip(),
"overdue": bool(due and due < today_iso),
})
return out
# ------------------------------------------------------------------ policy # ------------------------------------------------------------------ policy
DIGEST_POLICY_KEY = "digest_policy" DIGEST_POLICY_KEY = "digest_policy"
@@ -225,6 +264,55 @@ def load_digest_policy(conn):
return pol return pol
# ------------------------------------------------------------------ window
# Cap a manual/preview window so an admin can't accidentally fire a build over
# years of history — each active user in the window costs one Spark call. ~3
# months covers any realistic "since last quarter" preview.
MAX_WINDOW_DAYS = 92
def resolve_digest_window(*, hours=None, since=None, now_local=None, now_utc=None):
"""Resolve a digest content window to (since_iso, until_iso) as UTC ISO-8601.
`until` is always now. The start is driven by exactly one of:
- since: a local calendar date 'YYYY-MM-DD' -> that day's local midnight
- hours: a positive integer lookback (the default path; 24 when nothing given)
`since` wins if both are supplied. The span is clamped to MAX_WINDOW_DAYS and
the start must be strictly before now. Raises ValueError on malformed input so
the caller can return a clean 400. Pure (now_* injectable) for testing.
Used by the admin-panel preview and manual-send — neither advances the daily
cursor, so a wide window here never suppresses the scheduled digest."""
nu = (now_utc or datetime.now(timezone.utc)).astimezone(timezone.utc)
nl = now_local or datetime.now().astimezone()
floor = nu - timedelta(days=MAX_WINDOW_DAYS)
if since not in (None, ""):
try:
d = datetime.strptime(str(since).strip()[:10], "%Y-%m-%d")
except ValueError:
raise ValueError("since must be a date in YYYY-MM-DD form")
start = d.replace(tzinfo=nl.tzinfo or timezone.utc).astimezone(timezone.utc)
else:
h = 24 if hours in (None, "") else hours
try:
h = int(h)
except (ValueError, TypeError):
raise ValueError("hours must be an integer")
if h < 1:
raise ValueError("hours must be a positive integer")
start = nu - timedelta(hours=h)
if start >= nu:
raise ValueError("window start must be before now")
if start < floor:
start = floor # clamp to the max span (the response echoes the real window)
fmt = "%Y-%m-%dT%H:%M:%SZ"
return start.strftime(fmt), nu.strftime(fmt)
# ------------------------------------------------------------------ summarization # ------------------------------------------------------------------ summarization
def _default_chat(prompt, system=None, max_tokens=220): def _default_chat(prompt, system=None, max_tokens=220):
@@ -300,49 +388,72 @@ def _fmt_local(iso):
return f"{dt.strftime('%b')} {dt.day} {hour12}:{dt.minute:02d} {ampm}" return f"{dt.strftime('%b')} {dt.day} {hour12}:{dt.minute:02d} {ampm}"
def _compose_body(user_groups, investor_groups, narratives, since_iso, until_iso): def _reminders_section(due_reminders):
"""Render the 'reminders due' block (overdue + due today). An empty list renders
nothing, so a clear deck adds no noise to the digest."""
if not due_reminders:
return []
overdue = [r for r in due_reminders if r["overdue"]]
due_today = [r for r in due_reminders if not r["overdue"]]
def _line(r):
inv = f"{r['investor_name']}" if r["investor_name"] else ""
who = f" [{r['assignee']}]" if r["assignee"] else ""
return f"{inv}{r['title']} (due {r['due_date']}){who}"
L = ["", _RULE, f"REMINDERS DUE ({len(due_reminders)})", _RULE]
if overdue:
L += ["", f"Overdue ({len(overdue)}):"] + [_line(r) for r in overdue]
if due_today:
L += ["", f"Due today ({len(due_today)}):"] + [_line(r) for r in due_today]
return L
def _compose_body(user_groups, investor_groups, narratives, since_iso, until_iso, due_reminders=None):
title_date = datetime.now().astimezone().strftime("%A, %b %d %Y") title_date = datetime.now().astimezone().strftime("%A, %b %d %Y")
window = f"{_fmt_local(since_iso)} {_fmt_local(until_iso)}" window = f"{_fmt_local(since_iso)} {_fmt_local(until_iso)}"
L = ["Ten31 CRM — Daily Activity Digest", title_date, f"Window: {window}", ""] L = ["Ten31 CRM — Daily Activity Digest", title_date, f"Window: {window}", ""]
if not user_groups: if not user_groups:
L += ["No tracked email activity from any user in this window.", "", _FOOTER] L.append("No tracked email activity from any user in this window.")
return "\n".join(L) else:
total_emails = sum(g["total"] for g in user_groups)
total_invs = len({i for g in user_groups for i in g["investors"]})
L.append(f"{len(user_groups)} team member(s) active · {total_emails} email(s) "
f"· {total_invs} investor(s)")
total_emails = sum(g["total"] for g in user_groups) # ── Section 1: by team member (who did what; per-user Spark narrative) ──
total_invs = len({i for g in user_groups for i in g["investors"]}) L += ["", _RULE, "BY TEAM MEMBER", _RULE]
L.append(f"{len(user_groups)} team member(s) active · {total_emails} email(s) " for g in user_groups:
f"· {total_invs} investor(s)") invs = ", ".join(g["investors"]) or "(no matched investor)"
L += ["",
f"{g['full_name'] or g['username']} · {g['account_email']}",
f"{g['total']} email(s) ({g['sent']} sent, {g['received']} received) "
f"· {invs}", "",
narratives.get(g["user_id"], ""), ""]
for em in g["emails"]:
arrow = "→ Sent" if em["direction"] == "sent" else "← Received"
invs_e = ", ".join(em["investors"]) or "(unmatched)"
subj = em.get("subject") or "(no subject)"
L.append(f" {arrow} · {invs_e} · \"{subj}\" ({_fmt_local(em['sent_at'])})")
# ── Section 1: by team member (who did what; per-user Spark narrative) ── # ── Section 2: by investor (team-wide; both directions, structured) ──
L += ["", _RULE, "BY TEAM MEMBER", _RULE] L += ["", _RULE, "BY INVESTOR", _RULE]
for g in user_groups: for inv in investor_groups:
invs = ", ".join(g["investors"]) or "(no matched investor)" L += ["",
L += ["", f"{inv['name']} · {inv['total']} email(s) "
f"{g['full_name'] or g['username']} · {g['account_email']}", f"({inv['inbound']} in, {inv['outbound']} out)"]
f"{g['total']} email(s) ({g['sent']} sent, {g['received']} received) " for em in inv["emails"]:
f"· {invs}", "", subj = em.get("subject") or "(no subject)"
narratives.get(g["user_id"], ""), ""] when = _fmt_local(em["sent_at"])
for em in g["emails"]: if em["direction"] == "out":
arrow = "→ Sent" if em["direction"] == "sent" else "← Received" who = ", ".join(em["members"]) or "team"
invs_e = ", ".join(em["investors"]) or "(unmatched)" L.append(f" → Sent by {who} · \"{subj}\" ({when})")
subj = em.get("subject") or "(no subject)" else:
L.append(f" {arrow} · {invs_e} · \"{subj}\" ({_fmt_local(em['sent_at'])})") L.append(f" ← Received · \"{subj}\" ({when})")
# ── Section 2: by investor (team-wide; both directions, structured) ── # ── Reminders due (current state — independent of the activity window) ──
L += ["", _RULE, "BY INVESTOR", _RULE] L += _reminders_section(due_reminders or [])
for inv in investor_groups:
L += ["",
f"{inv['name']} · {inv['total']} email(s) "
f"({inv['inbound']} in, {inv['outbound']} out)"]
for em in inv["emails"]:
subj = em.get("subject") or "(no subject)"
when = _fmt_local(em["sent_at"])
if em["direction"] == "out":
who = ", ".join(em["members"]) or "team"
L.append(f" → Sent by {who} · \"{subj}\" ({when})")
else:
L.append(f" ← Received · \"{subj}\" ({when})")
L += ["", _RULE, _FOOTER] L += ["", _RULE, _FOOTER]
return "\n".join(L) return "\n".join(L)
@@ -350,14 +461,16 @@ def _compose_body(user_groups, investor_groups, narratives, since_iso, until_iso
def build_digest(conn, since_iso, until_iso, chat_fn=None): def build_digest(conn, since_iso, until_iso, chat_fn=None):
"""Build the daily digest for [since_iso, until_iso). Returns """Build the daily digest for [since_iso, until_iso). Returns
{subject, body, has_activity, user_count, email_count, investor_count}. Always {subject, body, has_activity, user_count, email_count, investor_count,
returns a body (empty windows get a 'no activity' note — the team chose reminder_count}. Always returns a body (empty windows get a 'no activity' note —
always-send). Two sections: by team member (per-user Spark narrative) and by the team chose always-send). Sections: by team member (per-user Spark narrative),
investor (structured, both directions).""" by investor (structured), and reminders due (overdue + due today, current-state)."""
user_groups = collect_user_activity(conn, since_iso, until_iso) user_groups = collect_user_activity(conn, since_iso, until_iso)
investor_groups = collect_investor_activity(conn, since_iso, until_iso) investor_groups = collect_investor_activity(conn, since_iso, until_iso)
narratives = {g["user_id"]: summarize_user_day(g, chat_fn) for g in user_groups} narratives = {g["user_id"]: summarize_user_day(g, chat_fn) for g in user_groups}
body = _compose_body(user_groups, investor_groups, narratives, since_iso, until_iso) today_iso = datetime.now().astimezone().strftime("%Y-%m-%d")
due_reminders = collect_due_reminders(conn, today_iso)
body = _compose_body(user_groups, investor_groups, narratives, since_iso, until_iso, due_reminders)
stamp = datetime.now().astimezone().strftime("%b %d") stamp = datetime.now().astimezone().strftime("%b %d")
return { return {
"subject": f"Ten31 CRM — Daily Activity Digest · {stamp}", "subject": f"Ten31 CRM — Daily Activity Digest · {stamp}",
@@ -366,4 +479,5 @@ def build_digest(conn, since_iso, until_iso, chat_fn=None):
"user_count": len(user_groups), "user_count": len(user_groups),
"email_count": sum(g["total"] for g in user_groups), "email_count": sum(g["total"] for g in user_groups),
"investor_count": len(investor_groups), "investor_count": len(investor_groups),
"reminder_count": len(due_reminders),
} }
+269
View File
@@ -398,6 +398,275 @@ def start_sync_run(conn: sqlite3.Connection, *, account_id: str, kind: str) -> s
return run_id return run_id
def _resolve_entity(row) -> tuple:
"""Reduce one email_investor_links hydration row to a (key, name) identity for
the matched investor, with the same precedence the digest uses:
grid investor -> organization -> contact -> raw matched address. The key is
*typed* (`fund:`/`org:`/`contact:`/`addr:`) so the Communications filter can
target the right column. Soft-deleted org/contact rows arrive as NULL (filtered
in the join) and fall through to the next tier."""
if row["fund_id"] and (row["fund_name"] or "").strip():
return f"fund:{row['fund_id']}", row["fund_name"].strip()
if row["org_id"] and (row["org_name"] or "").strip():
return f"org:{row['org_id']}", row["org_name"].strip()
if row["contact_id"] and (row["contact_name"] or "").strip():
return f"contact:{row['contact_id']}", row["contact_name"].strip()
addr = (row["addr"] or "").strip()
return (f"addr:{addr.lower()}", addr) if addr else (None, None)
# Hydration of an email_investor_links row up to the resolvable investor identity,
# shared by the per-email tags and the facet dropdown. Soft-deleted org/contact
# rows are dropped in the join so they fall through to the next identity tier.
_LINK_IDENTITY_JOINS = """
LEFT JOIN fundraising_investors fi ON fi.id = l.fundraising_investor_id
LEFT JOIN fundraising_contacts fic ON fic.id = l.fundraising_contact_id
LEFT JOIN fundraising_investors fic_inv ON fic_inv.id = fic.investor_id
LEFT JOIN organizations o ON o.id = l.organization_id AND o.deleted_at IS NULL
LEFT JOIN contacts c ON c.id = l.contact_id AND c.deleted_at IS NULL
"""
_LINK_IDENTITY_COLS = """
l.matched_address AS addr,
COALESCE(fi.id, fic_inv.id) AS fund_id,
COALESCE(fi.investor_name, fic_inv.investor_name) AS fund_name,
COALESCE(fi.graveyard, fic_inv.graveyard) AS fund_graveyard,
o.id AS org_id, o.name AS org_name,
c.id AS contact_id,
NULLIF(TRIM(COALESCE(c.first_name,'') || ' ' || COALESCE(c.last_name,'')), '') AS contact_name
"""
def query_email_activity(conn: sqlite3.Connection, *, investor_id: Optional[str] = None,
account_id: Optional[str] = None, search: Optional[str] = None,
direction: Optional[str] = None, since: Optional[str] = None,
until: Optional[str] = None, limit: int = 100) -> dict:
"""Captured-Gmail activity for the admin Communications panel, filterable by
matched investor entity, mailbox, direction and date range, with free-text
search over subject/snippet/sender. Returns the email rows plus the filter facets.
Matched-only: the panel shows ONLY email that links to a known investor/contact
(an `email_investor_links` row exists). Unmatched cold/unknown-sender email is
still captured for completeness but is never surfaced here.
Investor identity is *typed* (`fund:`/`org:`/`contact:`/`addr:`) and resolved with
the digest's precedence (grid investor -> organization -> contact -> raw address),
so an email matched only to a classic contact or an org domain — not yet wired to a
grid investor — still shows a real name and is selectable in the dropdown, instead
of the facet coming back empty. `investor_id` accepts a typed key (a bare id is
treated as `fund:` for backward compatibility).
Soft-delete: an email is live only if it still has a non-tombstoned per-mailbox
sighting (`email_account_messages.deleted_at IS NULL`) — the `emails` row itself
carries no deleted_at, so deletion lives on the sighting. Direction is decided at
the email level (outbound if the sender is one of our mailboxes), mirroring the
digest builder, so a thread reads consistently regardless of which mailbox saw it.
"""
limit = max(1, min(int(limit or 100), 500))
cur = conn.cursor()
own = {(r["email_address"] or "").lower().strip()
for r in cur.execute("SELECT email_address FROM email_accounts")}
own.discard("")
where = ["EXISTS (SELECT 1 FROM email_account_messages eam "
"WHERE eam.email_id = e.id AND eam.deleted_at IS NULL)",
# Matched-only: surface email that links to a known investor/contact.
# Unmatched (unknown-sender) email is captured but never shown here.
"EXISTS (SELECT 1 FROM email_investor_links l WHERE l.email_id = e.id)"]
params: list = []
if account_id:
where.append("EXISTS (SELECT 1 FROM email_account_messages eam "
"WHERE eam.email_id = e.id AND eam.account_id = ? "
"AND eam.deleted_at IS NULL)")
params.append(account_id)
if investor_id:
kind, _, val = str(investor_id).partition(":")
if not val: # bare id (legacy) -> grid investor
kind, val = "fund", str(investor_id)
if kind == "fund":
where.append("EXISTS (SELECT 1 FROM email_investor_links l WHERE l.email_id = e.id "
"AND (l.fundraising_investor_id = ? OR l.fundraising_contact_id IN "
"(SELECT id FROM fundraising_contacts WHERE investor_id = ?)))")
params.extend([val, val])
elif kind == "org":
where.append("EXISTS (SELECT 1 FROM email_investor_links l "
"WHERE l.email_id = e.id AND l.organization_id = ?)")
params.append(val)
elif kind == "contact":
where.append("EXISTS (SELECT 1 FROM email_investor_links l "
"WHERE l.email_id = e.id AND l.contact_id = ?)")
params.append(val)
elif kind == "addr":
where.append("EXISTS (SELECT 1 FROM email_investor_links l "
"WHERE l.email_id = e.id AND LOWER(l.matched_address) = ?)")
params.append(val.lower())
else:
# Unknown key prefix (malformed input) -> match nothing, never silently
# fall through to an unfiltered list.
where.append("1 = 0")
if search:
like = f"%{search.strip()}%"
where.append("(e.subject LIKE ? OR e.snippet LIKE ? "
"OR e.from_email LIKE ? OR e.from_name LIKE ?)")
params.extend([like, like, like, like])
# Date range over the send time (ISO-8601 strings sort lexically). [since, until).
if since:
where.append("e.sent_at >= ?")
params.append(since)
if until:
where.append("e.sent_at < ?")
params.append(until)
direction = (direction or "").strip().lower()
if direction in ("inbound", "outbound") and own:
marks = ",".join("?" for _ in own)
op = "IN" if direction == "outbound" else "NOT IN"
where.append(f"LOWER(e.from_email) {op} ({marks})")
params.extend(sorted(own))
sql = ("SELECT e.id, e.subject, e.from_name, e.from_email, e.sent_at, e.snippet, "
"e.has_attachments, e.is_matched, e.match_status FROM emails e WHERE "
+ " AND ".join(where) + " ORDER BY e.sent_at DESC LIMIT ?")
rows = [dict(r) for r in cur.execute(sql, params + [limit + 1])]
truncated = len(rows) > limit
rows = rows[:limit]
by_id = {r["id"]: r for r in rows}
for r in rows:
r["direction"] = "outbound" if (r["from_email"] or "").lower().strip() in own else "inbound"
r["mailboxes"] = []
r["investors"] = [] # [{id: typed-key, name}] — resolved identities
ids = list(by_id)
if ids:
marks = ",".join("?" for _ in ids)
for s in cur.execute(
"SELECT eam.email_id AS eid, ea.email_address AS addr "
"FROM email_account_messages eam JOIN email_accounts ea ON ea.id = eam.account_id "
f"WHERE eam.deleted_at IS NULL AND eam.email_id IN ({marks}) "
"ORDER BY ea.email_address", ids):
mb = by_id[s["eid"]]["mailboxes"]
if s["addr"] and s["addr"] not in mb:
mb.append(s["addr"])
for lnk in cur.execute(
f"SELECT l.email_id AS eid, {_LINK_IDENTITY_COLS} "
f"FROM email_investor_links l {_LINK_IDENTITY_JOINS} "
f"WHERE l.email_id IN ({marks})", ids):
# No graveyard filter here on purpose: a graveyarded investor's *email*
# still shows in the list with its chip (audit completeness, direct or
# via-contact); only the facet dropdown below hides graveyard from the picker.
key, name = _resolve_entity(lnk)
if not key:
continue
invs = by_id[lnk["eid"]]["investors"]
if not any(iv["id"] == key for iv in invs):
invs.append({"id": key, "name": name})
accounts = [dict(r) for r in cur.execute(
"SELECT id, email_address FROM email_accounts ORDER BY email_address")]
# Facet dropdown mirrors what the list resolves: one entry per distinct matched
# entity (grid investor / org / contact), across all live matched email — not just
# the current page — so the picker is stable under filtering. Excluded from the
# picker: graveyarded grid investors (CRM-wide convention) and raw-address-only
# matches (too many, too noisy). Both still appear in the list and remain findable
# by free-text search — this is an audit surface, so history is never hidden, only
# the picker is.
facet: dict[str, str] = {}
for r in cur.execute(
f"SELECT DISTINCT {_LINK_IDENTITY_COLS} "
f"FROM email_investor_links l {_LINK_IDENTITY_JOINS} "
"WHERE EXISTS (SELECT 1 FROM email_account_messages eam "
"WHERE eam.email_id = l.email_id AND eam.deleted_at IS NULL)"):
key, name = _resolve_entity(r)
if not key or key.startswith("addr:"):
continue
if key.startswith("fund:") and (r["fund_graveyard"] or 0):
continue
facet.setdefault(key, name)
investors = [{"id": k, "name": v}
for k, v in sorted(facet.items(), key=lambda kv: kv[1].lower())]
return {"emails": rows, "accounts": accounts, "investors": investors,
"count": len(rows), "truncated": truncated}
def search_hit_emails(conn: sqlite3.Connection, email_ids) -> dict:
"""Display fields for the given email ids that are still live (have a
non-tombstoned sighting), keyed by id, with email-level direction.
Used to hydrate + soft-delete-filter semantic-search hits: the Qdrant index can
lag a deletion, and SQLite is canonical (never trust the derived index), so a hit
whose email no longer has a live sighting is dropped here rather than shown."""
ids = [i for i in dict.fromkeys(email_ids) if i]
if not ids:
return {}
cur = conn.cursor()
own = {(r["email_address"] or "").lower().strip()
for r in cur.execute("SELECT email_address FROM email_accounts")}
own.discard("")
marks = ",".join("?" for _ in ids)
out: dict = {}
for e in cur.execute(
"SELECT e.id, e.subject, e.from_name, e.from_email, e.sent_at, e.has_attachments "
f"FROM emails e WHERE e.id IN ({marks}) AND EXISTS (SELECT 1 FROM email_account_messages eam "
"WHERE eam.email_id = e.id AND eam.deleted_at IS NULL)", ids):
d = dict(e)
d["direction"] = "outbound" if (d["from_email"] or "").lower().strip() in own else "inbound"
out[d["id"]] = d
return out
def query_email_detail(conn: sqlite3.Connection, email_id: str) -> Optional[dict]:
"""Full record for one captured email — the Communications detail view (full
body + recipients + matched investor identities + mailboxes + attachments).
Returns None if the email doesn't exist or has no live (non-tombstoned) sighting:
soft-delete lives on the per-mailbox `email_account_messages` row, not on `emails`,
so an email is only "live" while at least one sighting survives. Direction is set
at the email level (outbound if the sender is one of our mailboxes), matching the
list. The raw remote `body_html` is NOT returned (XSS); the response carries the
plain-text `body_text` plus a `has_html` flag so the UI can note an HTML-only email."""
cur = conn.cursor()
e = cur.execute(
"SELECT e.id, e.subject, e.from_name, e.from_email, e.sent_at, e.snippet, "
"e.body_text, e.body_html, e.has_attachments, e.match_status, e.gmail_thread_id "
"FROM emails e WHERE e.id = ? AND EXISTS (SELECT 1 FROM email_account_messages eam "
"WHERE eam.email_id = e.id AND eam.deleted_at IS NULL)", (email_id,)).fetchone()
if not e:
return None
row = dict(e)
# Don't ship the raw remote HTML to the client (XSS if any consumer ever renders
# it); the UI shows the plain-text body and only needs to know HTML exists.
row["has_html"] = bool((row.pop("body_html", None) or "").strip())
own = {(r["email_address"] or "").lower().strip()
for r in cur.execute("SELECT email_address FROM email_accounts")}
own.discard("")
row["direction"] = "outbound" if (row["from_email"] or "").lower().strip() in own else "inbound"
row["mailboxes"] = [r["addr"] for r in cur.execute(
"SELECT DISTINCT ea.email_address AS addr FROM email_account_messages eam "
"JOIN email_accounts ea ON ea.id = eam.account_id "
"WHERE eam.email_id = ? AND eam.deleted_at IS NULL ORDER BY ea.email_address", (email_id,))]
row["recipients"] = [dict(r) for r in cur.execute(
"SELECT address, display_name, kind FROM email_recipients "
"WHERE email_id = ? AND kind IN ('to','cc') "
"ORDER BY CASE kind WHEN 'to' THEN 0 ELSE 1 END, address", (email_id,))]
row["attachments"] = [dict(r) for r in cur.execute(
"SELECT filename, mime_type, size_bytes, download_status FROM email_attachments "
"WHERE email_id = ? ORDER BY filename", (email_id,))]
investors: dict[str, str] = {}
for lnk in cur.execute(
f"SELECT {_LINK_IDENTITY_COLS} FROM email_investor_links l {_LINK_IDENTITY_JOINS} "
"WHERE l.email_id = ?", (email_id,)):
key, name = _resolve_entity(lnk)
if key:
investors.setdefault(key, name)
row["investors"] = [{"id": k, "name": v} for k, v in investors.items()]
return row
def finish_sync_run(conn: sqlite3.Connection, run_id: str, *, status: str, def finish_sync_run(conn: sqlite3.Connection, run_id: str, *, status: str,
stats: Optional[dict] = None, error: Optional[str] = None) -> None: stats: Optional[dict] = None, error: Optional[str] = None) -> None:
stats = stats or {} stats = stats or {}
+26 -5
View File
@@ -100,15 +100,36 @@ def _build_and_send(conn, since_iso, until_iso, *, build_fn=None, send_fn=None):
} }
def send_digest_window(conn_factory=None, *, since_iso, until_iso,
build_fn=None, send_fn=None):
"""Build the digest for an explicit (since_iso, until_iso] window and send it
to the active-admin set now, WITHOUT advancing the daily cursor — a manual or
preview send must never suppress the scheduled daily digest. Same transport +
recipient rules as the daily path (raises digest_mailer.NoTransport when none
is configured / no admin has an address). Backs the admin 'send now' endpoint.
No DB writes happen here (the cursor is deliberately untouched), so the connection
is opened and closed without a commit — don't add one without revisiting that."""
factory = conn_factory or _conn_factory_from_env()
conn = factory()
try:
result = _build_and_send(conn, since_iso, until_iso,
build_fn=build_fn, send_fn=send_fn)
return {"status": "sent", **result}
finally:
conn.close()
def maybe_send_digest(conn_factory=None, *, force=False, def maybe_send_digest(conn_factory=None, *, force=False,
now_local=None, now_utc=None, build_fn=None, send_fn=None): now_local=None, now_utc=None, build_fn=None, send_fn=None):
"""Send the daily digest if it is due (or unconditionally when force=True). """Send the daily digest if it is due (or unconditionally when force=True).
Daily path: skips before the send hour and if already sent today; content Daily path (the scheduler loop): skips before the send hour and if already sent
window runs from the last send to now and the cursor advances on success. today; content window runs from the last send to now and the cursor advances on
force path (the admin 'send now' endpoint): ignores the policy and the guards, success. force path: ignores the policy and the guards, uses a fixed last-24h
uses a fixed last-24h window, and does NOT advance the daily cursor — so an window, and does NOT advance the daily cursor. (The admin 'send now' / preview
on-demand preview never suppresses the scheduled send.""" endpoints now use send_digest_window for an arbitrary window; force is retained
for the fixed last-24h case and its tests.)"""
import digest_builder import digest_builder
factory = conn_factory or _conn_factory_from_env() factory = conn_factory or _conn_factory_from_env()
@@ -0,0 +1,30 @@
-- ============================================================================
-- email_proposal_matrix — Matrix-review state for an email_activity_proposal,
-- kept 1:1 with the proposal (proposal_id PK). The CRM runs on the box and has
-- no matrix-nio, so it cannot post to Matrix itself: the intake bot (on the Spark)
-- PULLS pending proposals, posts a review card to the dedicated Matrix review room,
-- and writes the thread-root event_id back here. Persisting it CRM-side (not just in
-- the bot's memory) keeps both surfaces in sync and survives a bot restart.
--
-- A SIDE TABLE rather than new columns on email_activity_proposals because the
-- email-integration migration runner (email_integration/db.py:apply_migrations)
-- re-runs every .sql file on every boot via executescript with no ledger — so
-- CREATE TABLE IF NOT EXISTS is idempotent, whereas ALTER ... ADD COLUMN would throw
-- "duplicate column" on the second boot and abort startup. Reversal: DROP TABLE
-- (this runner has no .down.sql convention; cf. 0001/0002).
--
-- posted_at — set once the bot has posted the review card (event_id = thread root).
-- closed_at — set when the thread is resolved: either the bot decided in-thread, OR
-- the bot announced a web-side decision. A posted+decided proposal with
-- closed_at NULL is exactly the bot's signal to post "decided on the web"
-- into the thread and then close it.
-- ============================================================================
CREATE TABLE IF NOT EXISTS email_proposal_matrix (
proposal_id TEXT PRIMARY KEY,
event_id TEXT, -- Matrix thread-root event id of the posted review card
posted_at TEXT,
closed_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(proposal_id) REFERENCES email_activity_proposals(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_email_proposal_matrix_event ON email_proposal_matrix(event_id);
+129 -5
View File
@@ -33,6 +33,9 @@ from . import scheduler as _sched
_GET_ROUTES = { _GET_ROUTES = {
"/api/email/status": "status", "/api/email/status": "status",
"/api/email/accounts": "list_accounts", "/api/email/accounts": "list_accounts",
"/api/email/activity": "activity",
"/api/email/detail": "detail",
"/api/email/search": "search",
"/api/email/threads": "list_threads", "/api/email/threads": "list_threads",
"/api/email/oauth/start": "oauth_start", "/api/email/oauth/start": "oauth_start",
"/api/email/oauth/callback": "oauth_callback", "/api/email/oauth/callback": "oauth_callback",
@@ -115,7 +118,9 @@ def _require_admin(handler) -> Optional[dict]:
# ---------------------------------------------------------------------------- GET handlers # ---------------------------------------------------------------------------- GET handlers
def _h_status(handler): def _h_status(handler):
user = _require_auth(handler) # Email Capture is an admin-only surface (nav-hidden from members); these read
# endpoints expose mailbox/sync metadata, so enforce admin server-side too.
user = _require_admin(handler)
if not user: if not user:
return return
snap = _sched.status_snapshot() snap = _sched.status_snapshot()
@@ -150,7 +155,9 @@ def _h_status(handler):
def _h_list_accounts(handler): def _h_list_accounts(handler):
user = _require_auth(handler) # Admin-only: the mailbox list (addresses, sync state, errors) belongs to the
# admin-only Email Capture surface. Enforced server-side, not just nav-hidden.
user = _require_admin(handler)
if not user: if not user:
return return
conn = _conn() conn = _conn()
@@ -180,12 +187,129 @@ def _h_list_accounts(handler):
r["matched"] = matched.get(r["id"], 0) r["matched"] = matched.get(r["id"], 0)
finally: finally:
conn.close() conn.close()
# Non-admins only see their own row
if user.get("role") != "admin":
rows = [r for r in rows if r["user_id"] == user["user_id"]]
handler.send_json({"accounts": rows}) handler.send_json({"accounts": rows})
def _h_activity(handler):
# Admin-only: the Communications page renders captured-Gmail activity (the classic
# manual-log surface was retired). Mailbox/investor substance is admin-scoped, so
# enforce admin server-side, not just nav-hide.
user = _require_admin(handler)
if not user:
return
q = handler.get_query_params()
try:
limit = int(q.get("limit", 100))
except (TypeError, ValueError):
limit = 100
conn = _conn()
try:
result = _db.query_email_activity(
conn,
investor_id=(q.get("investor_id") or "").strip() or None,
account_id=(q.get("account_id") or "").strip() or None,
search=(q.get("q") or q.get("search") or "").strip() or None,
direction=(q.get("direction") or "").strip() or None,
since=(q.get("since") or "").strip() or None,
until=(q.get("until") or "").strip() or None,
limit=limit,
)
finally:
conn.close()
handler.send_json(result)
def _h_detail(handler):
# Admin-only: the full body + recipients of a captured email is admin-scoped
# substance, same as the activity list it expands from.
user = _require_admin(handler)
if not user:
return
email_id = (handler.get_query_params().get("id") or "").strip()
if not email_id:
return handler.send_error_json("id required", 400)
conn = _conn()
try:
detail = _db.query_email_detail(conn, email_id)
finally:
conn.close()
if detail is None:
return handler.send_error_json("Not found", 404)
handler.send_json(detail)
def _semantic_email_search(query: str, top_k: int) -> list:
"""Hybrid (dense + BM25, reranked) retrieval over the email bodies indexed in
Qdrant, pre-filtered to doc_type='email'. Returns raw ranked hits (payload carries
source_id=email_id, lp_name, date_ts, text). The ingest stack (Spark Control +
Qdrant + the sparse encoder) lives in the Docker image, so it's imported lazily —
a bare CRM without it raises, and the caller maps that to a 503."""
import os
import sys
ingest_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ingest")
if ingest_dir not in sys.path:
sys.path.insert(0, ingest_dir)
import search as _ingest_search # ingest/search.py
filt = {"must": [{"key": "doc_type", "match": {"value": "email"}}]}
return _ingest_search.hybrid_search(query, top_k=top_k, rerank=True, filt=filt)
def _h_search(handler):
# Admin-only semantic search over captured email *content* (bodies), distinct from
# the structured subject/sender filters in _h_activity. Matched email bodies are the
# only email indexed in Qdrant (see ingest/chunking). Soft-delete-filtered + hydrated
# against SQLite (canonical) so a deleted email never surfaces from the stale index.
user = _require_admin(handler)
if not user:
return
q = handler.get_query_params()
query = (q.get("q") or q.get("query") or "").strip()
if not query:
return handler.send_json({"query": "", "results": []})
try:
top_k = min(50, max(1, int(q.get("top_k", 25))))
except (TypeError, ValueError):
top_k = 25
try:
hits = _semantic_email_search(query, top_k)
except Exception as e:
# Spark Control / Qdrant unreachable, or the ingest stack isn't installed.
# Log server-side (an error can carry a URL/host); give the UI a clean 503.
import sys
print(f"[email-search] retrieval failed: {type(e).__name__}: {e}", file=sys.stderr)
return handler.send_error_json("Content search is unavailable (Spark/Qdrant not reachable).", 503)
# Hydrate + soft-delete-filter against SQLite (canonical), preserving rank order.
payloads = [(h.get("payload", {}) or {}, h) for h in hits]
ids = [p.get("source_id") for p, _ in payloads]
conn = _conn()
try:
live = _db.search_hit_emails(conn, ids)
finally:
conn.close()
results = []
for p, h in payloads:
eid = p.get("source_id")
e = live.get(eid)
if not e:
continue # deleted since indexing, or not matched-resolvable -> drop
results.append({
"email_id": eid,
"subject": e["subject"],
"from_name": e["from_name"],
"from_email": e["from_email"],
"sent_at": e["sent_at"],
"direction": e["direction"],
"has_attachments": e["has_attachments"],
"lp_name": p.get("lp_name"),
"score": h.get("score"),
"excerpt": (h.get("text") or p.get("text") or "").replace("\n", " ").strip()[:300],
})
handler.send_json({"query": query, "results": results, "count": len(results)})
def _h_list_threads(handler): def _h_list_threads(handler):
user = _require_auth(handler) user = _require_auth(handler)
if not user: if not user:
@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""Test the admin-only email-activity panel (Communications tab, v0.1.0:80).
Covers the pure query (`db.query_email_activity`): matched-only scope (unmatched
cold/unknown-sender email is never surfaced), investor/mailbox/search/direction/
date-range filters, per-sighting soft-delete, direction at the email level, mailbox
roll-ups, and the *typed* investor facet (grid investor / org / contact), including
the v83 fix where an email matched only to a classic contact or org domain — not yet
wired to a grid investor — still resolves to a real name and appears in the dropdown
(previously the facet came back empty). Also asserts the route handler enforces admin
server-side. Synthetic data only.
Run: cd backend && python3 email_integration/test_email_activity_panel.py
"""
import os
import sqlite3
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from email_integration import db as _db # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
def make_db():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE email_accounts (id TEXT PRIMARY KEY, email_address TEXT);
CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, from_name TEXT, from_email TEXT,
sent_at TEXT, snippet TEXT, body_text TEXT, body_html TEXT, gmail_thread_id TEXT,
has_attachments INT DEFAULT 0, is_matched INT DEFAULT 0,
match_status TEXT DEFAULT 'unmatched');
CREATE TABLE email_account_messages (id TEXT PRIMARY KEY, email_id TEXT, account_id TEXT,
is_sent INT DEFAULT 0, deleted_at TEXT);
CREATE TABLE email_recipients (id TEXT PRIMARY KEY, email_id TEXT, address TEXT,
display_name TEXT, kind TEXT);
CREATE TABLE email_attachments (id TEXT PRIMARY KEY, email_id TEXT, filename TEXT,
mime_type TEXT, size_bytes INTEGER, download_status TEXT);
CREATE TABLE email_investor_links (id TEXT PRIMARY KEY, email_id TEXT,
fundraising_investor_id TEXT, fundraising_contact_id TEXT,
organization_id TEXT, contact_id TEXT, matched_address TEXT);
CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, graveyard INTEGER DEFAULT 0);
CREATE TABLE fundraising_contacts (id TEXT PRIMARY KEY, investor_id TEXT, full_name TEXT);
CREATE TABLE organizations (id TEXT PRIMARY KEY, name TEXT, deleted_at TEXT);
CREATE TABLE contacts (id TEXT PRIMARY KEY, first_name TEXT, last_name TEXT,
organization_id TEXT, deleted_at TEXT);
""")
# Two mailboxes (us); investors reached different ways: a grid investor directly,
# a grid investor only via a contact link, a graveyarded grid investor, an org-only
# (domain) match, and a classic-contact-only match (the case that left the dropdown
# empty before v83 — neither carries a grid id).
conn.executemany("INSERT INTO email_accounts VALUES (?,?)", [
("acc-grant", "grant@ten31.xyz"),
("acc-jon", "jonathan@ten31.xyz"),
])
conn.executemany("INSERT INTO fundraising_investors VALUES (?,?,?)", [
("inv-harbor", "Harbor & Vine", 0),
("inv-pacific", "Pacific Capital", 0),
("inv-dead", "Dead Deal LP", 1),
])
conn.execute("INSERT INTO fundraising_contacts VALUES ('fc-1','inv-pacific','Sarah Williams')")
conn.execute("INSERT INTO organizations VALUES ('org-bridge','Bridgewater',NULL)")
conn.execute("INSERT INTO contacts VALUES ('c-solo','Nina','Park',NULL,NULL)")
# Emails:
# e1 outbound -> Harbor (grid), seen by grant
# e2 inbound -> Harbor (grid), seen by grant + jonathan
# e3 inbound -> Pacific via grid contact link, seen by jonathan
# e4 inbound, UNMATCHED -> excluded (matched-only)
# e5 inbound, only sighting tombstoned -> excluded
# e6 inbound -> Dead Deal LP (graveyard grid investor)
# e7 inbound -> Bridgewater via ORG-domain match (no grid id)
# e8 inbound -> Nina Park via CLASSIC-contact match (no grid id, no org)
conn.executemany(
"INSERT INTO emails (id,subject,from_name,from_email,sent_at,snippet,has_attachments,is_matched,match_status) VALUES (?,?,?,?,?,?,?,?,?)",
[
("e1", "Fund III update", "Grant", "grant@ten31.xyz", "2026-06-05T10:00:00", "here is the deck", 1, 1, "matched"),
("e2", "Re: Fund III update", "LP Harbor", "lp@harborvine.example", "2026-06-06T09:00:00", "thanks, one question", 0, 1, "matched"),
("e3", "Intro", "Sarah Williams", "sarah@pacificcap.example", "2026-06-07T08:00:00", "would love to chat", 0, 1, "matched"),
("e4", "Cold inbound", "Random", "noreply@spam.example", "2026-06-08T08:00:00", "buy now", 0, 0, "unmatched"),
("e5", "Deleted thread", "Ghost", "ghost@x.example", "2026-06-09T08:00:00", "gone", 0, 1, "matched"),
("e6", "Old dead-deal thread", "Dead LP", "lp@deaddeal.example", "2026-06-01T00:00:00", "we passed", 0, 1, "matched"),
("e7", "Macro view", "Ray", "ray@bridgewater.example", "2026-06-10T08:00:00", "rates outlook", 0, 1, "matched"),
("e8", "Coffee?", "Nina Park", "nina@solo.example", "2026-06-11T08:00:00", "in town next week", 0, 1, "matched"),
])
conn.executemany(
"INSERT INTO email_account_messages (id,email_id,account_id,is_sent,deleted_at) VALUES (?,?,?,?,?)",
[
("m1", "e1", "acc-grant", 1, None),
("m2", "e2", "acc-grant", 0, None),
("m3", "e2", "acc-jon", 0, None),
("m4", "e3", "acc-jon", 0, None),
("m5", "e4", "acc-grant", 0, None),
("m6", "e5", "acc-grant", 0, "2026-06-10T00:00:00"), # tombstoned
("m7", "e6", "acc-grant", 0, None),
("m8", "e7", "acc-grant", 0, None),
("m9", "e8", "acc-jon", 0, None),
])
conn.executemany(
"INSERT INTO email_investor_links (id,email_id,fundraising_investor_id,fundraising_contact_id,organization_id,contact_id,matched_address) VALUES (?,?,?,?,?,?,?)",
[
("l1", "e1", "inv-harbor", None, None, None, "lp@harborvine.example"),
("l2", "e2", "inv-harbor", None, None, None, "lp@harborvine.example"),
("l3", "e3", None, "fc-1", None, None, "sarah@pacificcap.example"),
("l5", "e5", "inv-harbor", None, None, None, "lp@harborvine.example"),
("l6", "e6", "inv-dead", None, None, None, "lp@deaddeal.example"),
("l7", "e7", None, None, "org-bridge", None, "ray@bridgewater.example"),
("l8", "e8", None, None, None, "c-solo", "nina@solo.example"),
])
# Full body + recipients + an attachment on e2, for the detail view.
conn.execute("UPDATE emails SET body_text = ?, gmail_thread_id = ?, has_attachments = 1 WHERE id = 'e2'",
("Thanks for the deck — one question on the carry.", "thr-harbor"))
conn.executemany(
"INSERT INTO email_recipients (id,email_id,address,display_name,kind) VALUES (?,?,?,?,?)",
[
("r1", "e2", "grant@ten31.xyz", "Grant", "to"),
("r2", "e2", "jonathan@ten31.xyz", "Jonathan", "cc"),
("r3", "e2", "lp@harborvine.example", "LP Harbor", "from"), # from -> not surfaced
])
conn.execute("INSERT INTO email_attachments (id,email_id,filename,mime_type,size_bytes,download_status) "
"VALUES ('a1','e2','term_sheet.pdf','application/pdf',20480,'downloaded')")
conn.commit()
return conn
def ids(res):
return [e["id"] for e in res["emails"]]
def main():
conn = make_db()
# --- baseline: matched live emails only, newest first, tombstoned excluded ---
res = _db.query_email_activity(conn)
check(ids(res) == ["e8", "e7", "e3", "e2", "e1", "e6"],
f"matched live emails newest-first; e5 (tombstoned) + e4 (unmatched) excluded; got {ids(res)}")
check(res["count"] == 6 and res["truncated"] is False, "count + not truncated")
check("e4" not in ids(res), "unmatched email (no investor link) never surfaces in the panel")
# --- direction at the email level ---
e1 = next(e for e in res["emails"] if e["id"] == "e1")
e2 = next(e for e in res["emails"] if e["id"] == "e2")
check(e1["direction"] == "outbound", "e1 from our mailbox -> outbound")
check(e2["direction"] == "inbound", "e2 from LP -> inbound")
check(_db.query_email_activity(conn, direction="outbound")["emails"][0]["id"] == "e1"
and len(_db.query_email_activity(conn, direction="outbound")["emails"]) == 1,
"direction=outbound returns only e1")
check(ids(_db.query_email_activity(conn, direction="inbound")) == ["e8", "e7", "e3", "e2", "e6"],
"direction=inbound excludes the outbound e1 (and unmatched e4)")
# --- mailbox roll-up + per-account filter ---
check(set(e2["mailboxes"]) == {"grant@ten31.xyz", "jonathan@ten31.xyz"}, "e2 seen by both mailboxes")
check(ids(_db.query_email_activity(conn, account_id="acc-jon")) == ["e8", "e3", "e2"],
"account_id=acc-jon returns only emails that mailbox saw")
# --- date-range filter [since, until) over sent_at ---
check(ids(_db.query_email_activity(conn, since="2026-06-07T00:00:00", until="2026-06-11T00:00:00")) == ["e7", "e3"],
"date range [06-07, 06-11) -> e7,e3 (excludes 06-11 e8 and earlier e2/e1/e6)")
check(ids(_db.query_email_activity(conn, since="2026-06-10T00:00:00")) == ["e8", "e7"],
"since=06-10 -> e8,e7 only")
# --- investor filter: typed keys + legacy bare-id back-compat ---
check(set(ids(_db.query_email_activity(conn, investor_id="fund:inv-harbor"))) == {"e2", "e1"},
"investor_id=fund:inv-harbor -> e1,e2")
check(set(ids(_db.query_email_activity(conn, investor_id="inv-harbor"))) == {"e2", "e1"},
"legacy bare id treated as fund: -> e1,e2")
check(ids(_db.query_email_activity(conn, investor_id="fund:inv-pacific")) == ["e3"],
"fund:inv-pacific resolved through fundraising_contacts -> e3")
check(ids(_db.query_email_activity(conn, investor_id="org:org-bridge")) == ["e7"],
"org:org-bridge -> e7 (org-domain match)")
check(ids(_db.query_email_activity(conn, investor_id="contact:c-solo")) == ["e8"],
"contact:c-solo -> e8 (classic-contact match)")
check(_db.query_email_activity(conn, investor_id="bogus:x")["emails"] == [],
"unknown investor_id key prefix -> match nothing (never silently unfiltered)")
# --- investor identity roll-up, typed + resolved name ---
check(e1["investors"] == [{"id": "fund:inv-harbor", "name": "Harbor & Vine"}], "e1 grid investor resolved")
e3 = next(e for e in res["emails"] if e["id"] == "e3")
check(e3["investors"] == [{"id": "fund:inv-pacific", "name": "Pacific Capital"}], "e3 resolved via grid contact")
e7 = next(e for e in res["emails"] if e["id"] == "e7")
check(e7["investors"] == [{"id": "org:org-bridge", "name": "Bridgewater"}],
"e7 org-domain match resolves to the org name (not a raw address)")
e8 = next(e for e in res["emails"] if e["id"] == "e8")
check(e8["investors"] == [{"id": "contact:c-solo", "name": "Nina Park"}],
"e8 classic-contact match resolves to the contact name")
# --- free-text search over subject / snippet / sender ---
check(set(ids(_db.query_email_activity(conn, search="Fund III"))) == {"e1", "e2"}, "search subject")
check(ids(_db.query_email_activity(conn, search="pacificcap")) == ["e3"], "search sender address")
check(ids(_db.query_email_activity(conn, search="deck")) == ["e1"], "search snippet (matched email)")
check(ids(_db.query_email_activity(conn, search="buy now")) == [],
"unmatched email never surfaces, even by free-text search")
# --- facets: typed entries spanning grid / org / contact matches ---
check([a["email_address"] for a in res["accounts"]] == ["grant@ten31.xyz", "jonathan@ten31.xyz"],
"accounts facet sorted")
facet_inv = {i["id"] for i in res["investors"]}
check(facet_inv == {"fund:inv-harbor", "fund:inv-pacific", "org:org-bridge", "contact:c-solo"},
f"investor facet now mirrors the list (grid + org + contact), not just grid; got {facet_inv}")
check([i["name"] for i in res["investors"]] == sorted(i["name"] for i in res["investors"]),
"facet sorted by display name")
# --- graveyard: hidden from the picker, but its email stays visible + findable ---
check("fund:inv-dead" not in facet_inv, "graveyard investor excluded from the facet dropdown")
check("e6" in ids(res), "graveyard investor's email still shows in the unfiltered list (audit completeness)")
e6 = next(e for e in res["emails"] if e["id"] == "e6")
check(e6["investors"] == [{"id": "fund:inv-dead", "name": "Dead Deal LP"}], "graveyard email still shows its investor chip")
check(ids(_db.query_email_activity(conn, investor_id="fund:inv-dead")) == ["e6"],
"explicit investor_id filter still works for a graveyard investor")
check(ids(_db.query_email_activity(conn, search="deaddeal")) == ["e6"],
"graveyard email remains findable by free-text search")
# --- truncation ---
tr = _db.query_email_activity(conn, limit=2)
check(tr["count"] == 2 and tr["truncated"] is True, "limit=2 -> truncated")
# --- detail view (full body + recipients + attachments + identity) ---
d = _db.query_email_detail(conn, "e2")
check(d is not None and d["body_text"] == "Thanks for the deck — one question on the carry.",
"detail returns the full body")
check(d["direction"] == "inbound" and set(d["mailboxes"]) == {"grant@ten31.xyz", "jonathan@ten31.xyz"},
"detail direction + mailboxes")
check([(r["address"], r["kind"]) for r in d["recipients"]] ==
[("grant@ten31.xyz", "to"), ("jonathan@ten31.xyz", "cc")],
"detail recipients = to/cc only (from is excluded)")
check([a["filename"] for a in d["attachments"]] == ["term_sheet.pdf"], "detail lists attachments")
check(d["investors"] == [{"id": "fund:inv-harbor", "name": "Harbor & Vine"}], "detail resolves investor identity")
check(_db.query_email_detail(conn, "e5") is None,
"detail of a tombstoned-only email -> None (soft-delete on the sighting)")
check(_db.query_email_detail(conn, "nope") is None, "detail of a missing id -> None")
conn.close()
# --- route enforces admin server-side ---
test_route_admin_only()
# --- semantic content-search route (hydrate + soft-delete + 503) ---
test_search_route()
if FAILS:
print(f"\nFAILED ({len(FAILS)})")
for f in FAILS:
print(" - " + f)
sys.exit(1)
print("\nALL PASS (email-activity panel)")
class FakeHandler:
def __init__(self, user, params=None):
self._user = user
self._params = params or {}
self.json = None
self.err = None
self.code = None
def get_user(self):
return self._user
def get_query_params(self):
return self._params
def send_json(self, obj):
self.json = obj
def send_error_json(self, msg, code):
self.err = msg
self.code = code
def test_route_admin_only():
try:
from email_integration import routes
except Exception as e: # pragma: no cover - optional deps missing in some dev envs
print(f" SKIP route admin test (routes import failed: {e})")
return
h = FakeHandler(None)
routes._h_activity(h)
check(h.code == 401 and h.json is None, "route: no user -> 401")
h = FakeHandler({"role": "member", "user_id": "u1"})
routes._h_activity(h)
check(h.code == 403 and h.json is None, "route: member -> 403 (admin enforced server-side)")
def test_search_route():
try:
from email_integration import routes
except Exception as e: # pragma: no cover
print(f" SKIP search route test (routes import failed: {e})")
return
# Hydration source = a fresh fully-populated in-memory DB each call (the handler
# opens + closes its own conn). Retrieval is stubbed — no Spark/Qdrant in tests.
routes._conn = make_db
routes._semantic_email_search = lambda query, top_k: [
{"score": 0.91, "text": "carry discussion\nand terms", "payload": {"source_id": "e2", "lp_name": "Harbor & Vine"}},
{"score": 0.80, "text": "gone", "payload": {"source_id": "e5", "lp_name": "Ghost"}}, # tombstoned -> drop
{"score": 0.70, "text": "n/a", "payload": {"source_id": "missing", "lp_name": "Nobody"}}, # missing -> drop
]
h = FakeHandler({"role": "admin"}, {"q": "carry"})
routes._h_search(h)
check(h.json and [r["email_id"] for r in h.json["results"]] == ["e2"],
f"content search drops tombstoned + missing, keeps live e2; got {h.json and [r['email_id'] for r in h.json['results']]}")
top = h.json["results"][0]
check(top["lp_name"] == "Harbor & Vine" and top["score"] == 0.91 and top["subject"] == "Re: Fund III update",
"hit carries lp_name + score + hydrated subject")
check("\n" not in top["excerpt"], "excerpt is newline-flattened")
# empty query short-circuits (no retrieval call)
h = FakeHandler({"role": "admin"}, {"q": ""})
routes._h_search(h)
check(h.json == {"query": "", "results": []}, "empty query -> empty results")
# retrieval failure -> clean 503 (Spark/Qdrant down)
def _boom(query, top_k):
raise RuntimeError("spark down")
routes._semantic_email_search = _boom
h = FakeHandler({"role": "admin"}, {"q": "x"})
routes._h_search(h)
check(h.code == 503, f"retrieval failure -> 503, got {h.code}")
# admin enforced
h = FakeHandler({"role": "member"}, {"q": "x"})
routes._h_search(h)
check(h.code == 403, "content search admin-enforced server-side")
if __name__ == "__main__":
main()
+22
View File
@@ -0,0 +1,22 @@
# Container image for the Matrix intake bot — turns it from a bare nohup process into a managed
# service (docker compose `restart: unless-stopped` survives a Spark reboot).
#
# Build context is the REPO ROOT (see ../../docker-compose.yml), not this directory: the bot is
# NOT self-contained — spark.py reaches into backend/ingest/{llm,config,http_util}.py (stdlib
# only) via sys.path, so the image must carry both trees with the repo layout preserved. That
# keeps settings.load_env's REPO_ROOT (three dirs up from settings.py) = /app and spark.py's
# ingest path = /app/backend/ingest both correct at runtime.
FROM python:3.12-slim
WORKDIR /app
# The only third-party dep is matrix-nio; the reused ingest Spark client is pure stdlib.
COPY backend/matrix_intake/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/matrix_intake/ ./backend/matrix_intake/
COPY backend/ingest/ ./backend/ingest/
# .env (Matrix + CRM + Spark creds) is mounted read-only at /app/.env at runtime — never baked.
# `-u` keeps stdout/stderr unbuffered so `docker logs` shows the bot's lifecycle lines live.
CMD ["python", "-u", "backend/matrix_intake/bot.py"]
+41
View File
@@ -0,0 +1,41 @@
# Matrix intake bot
Turns a typed message in a dedicated Matrix room into a proposed fundraising-grid add/edit,
gated on in-thread human approval before any write. Runs as its own process (on the Spark),
separate from the CRM. Full design + rules: `docs/guides/matrix-intake.md`.
## Run
```bash
# 1. Install the one third-party dep (isolated to this component — NOT the CRM runtime)
python3 -m pip install -r requirements.txt # matrix-nio
# 2. Fill the MATRIX_* and CRM_BOT_* vars in the repo .env (see ../../.env.example),
# and create a dedicated CRM user for CRM_BOT_USERNAME/PASSWORD (admin → invite user).
# 3. Start the listener
python3 bot.py
```
It primes the Matrix sync past history (no backlog replay), then listens. Post a message in
the intake room; it replies in a thread with the parsed proposal. Reply **yes** to commit,
**edit field=value** to change a field, or **no** to discard.
## Layout
- `bot.py` — entrypoint: connect, prime-then-listen, dispatch (lifts matrix-bridge's plumbing).
- `parse.py` — message → structured proposal via local Qwen (`spark.py``backend/ingest/llm.py`).
- `proposals.py` — in-memory pending-proposal store + the yes/edit/no state machine.
- `crm_client.py` — login + `GET /api/intake/match` + write via `POST /api/fundraising/log-communication`.
- `matrix_io.py` — message splitting, thread-root detection, threaded-reply sender.
- `settings.py` — Matrix + CRM-API config (named `settings`, not `config`, to avoid shadowing `ingest/config`).
## Test (offline)
```bash
python3 test_parse.py && python3 test_proposals.py && python3 test_crm_client.py
# endpoint + create→match contract (boots the real server against a temp DB):
cd ../ && python3 test_intake_endpoints.py
```
Live Matrix behavior needs creds + `matrix-nio` and can only be smoke-tested on the Spark.
+7
View File
@@ -0,0 +1,7 @@
"""Matrix intake bot — a dedicated Matrix room that turns a typed message into a
proposed fundraising-grid add/edit, gated on in-thread human approval before any write.
Separate process from the CRM (its only third-party dep, matrix-nio, lives here, never
in the stdlib CRM runtime). Parses with local Qwen via Spark Control; on approval, writes
through the CRM's own API. See docs/guides/matrix-intake.md and ROADMAP.md.
"""
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""Matrix intake bot — entrypoint.
A top-level message in the dedicated intake room is parsed (local Qwen via Spark Control)
into a proposed fundraising-grid add/edit and posted back IN A THREAD. The team member
replies in that thread — **yes** / **edit field=value** / **no** — and only on **yes** does
the bot write, through the CRM's own API. Nothing is ever written autonomously.
Runs as its own process (its matrix-nio dep is isolated here, never in the CRM runtime).
Lifts matrix-bridge's prime-then-listen + threaded-reply plumbing. Config: repo .env.
"""
import asyncio
from nio import AsyncClient, MatrixRoom, MessageDirection, RoomMessageText
import crm_client
import email_proposals
import matrix_io
import parse
import proposals
import query
import settings
UNCLEAR_HELP = (
"🤔 I couldn't tell what to record. Try e.g.\n"
"`New investor: Acme Capital — Jane Doe <jane@acme.com>, met at the Austin conf`\n"
"or a note like `Note for Acme Capital: wants the Q3 deck, follow up next week`."
)
EMAIL_POLL_SEC = 20 # how often the bot polls the CRM for new/decided email-activity proposals
MAX_THREAD_SCAN_PAGES = 8 # how far back to scan for a resolved thread's replies before redacting
async def main():
mx = settings.matrix_settings()
client = AsyncClient(mx["homeserver"], mx["user_id"])
client.restore_login(user_id=mx["user_id"], device_id=mx["device_id"], access_token=mx["token"])
say = matrix_io.make_say(client)
nudge = matrix_io.make_reply(client)
store = proposals.ProposalStore()
intake_room = mx["intake_room"]
roster = settings.team_roster() # frames the parse: teammates do outreach, aren't prospects
if roster:
print(f"matrix-intake: team roster loaded ({len(roster)} names)", flush=True)
review_room = settings.email_review_room() # CRM-drafted email proposals (empty → feature off)
query_room = settings.query_room() # dedicated read-only Q&A room (empty → use the intake trigger)
email_threads = {} # Matrix thread-root event_id -> {id, investor_name, note} for an email proposal
async def handle_intake(room_id, root, text):
# A bare yes/no/approve typed in the MAIN timeline (not inside a proposal's thread) is
# an easy slip — point the user back to the thread rather than parse it as a new intake.
action, _ = proposals.interpret_reply(text)
if action in ("approve", "reject") and store.any_pending():
await nudge(room_id, "👉 To approve, reject, or edit a proposal, open its **thread** "
"and reply there — the note is in the thread.", root)
return
try:
proposal = await asyncio.to_thread(parse.parse_message, text, roster=roster)
except Exception as exc: # Spark/Qwen unreachable or bad response
await say(room_id, f"⚠️ couldn't reach the local parser: {str(exc)[:200]}", root)
return
if proposal["intent"] == "unclear":
await say(room_id, UNCLEAR_HELP, root)
return
# Resolve new-vs-existing against the CRM matcher (read-only). Degrade gracefully if the
# CRM is unreachable — still propose as new, just without match/candidate hints.
match, candidates = None, []
try:
res = await asyncio.to_thread(crm_client.match, proposal)
match = res.get("match")
candidates = res.get("candidates") or []
except Exception:
pass
if match:
# Confident exact match → auto-attach the note to that investor (no disambiguation).
proposal["intent"] = "meeting_note"
proposal["_match_id"] = match["id"]
proposal["_stage"] = "approval"
store.put(root, proposal)
hint = (f"\n\n🔎 Looks like an existing investor: **{match['name']}** — "
"this will append a note to them.")
await say(room_id, proposals.render(proposal) + hint, root)
await nudge(room_id, proposals.summary_line(proposal), root)
return
if candidates:
# No exact match but near-misses exist → make the human pick one or confirm "new",
# so a typo'd/near-duplicate name can't silently create a second investor.
proposal["_stage"] = "disambiguate"
proposal["_candidates"] = candidates
store.put(root, proposal)
await say(room_id, proposals.render_disambiguation(proposal), root)
await nudge(room_id, proposals.disambiguation_nudge(proposal), root)
return
# Genuinely new — straight to the new-investor approval card.
proposal["_stage"] = "approval"
store.put(root, proposal)
await say(room_id, proposals.render(proposal), root)
# Also drop a brief, un-threaded reply in the main timeline so the proposal isn't
# easy to miss inside a thread (the full card + yes/edit/no stay in the thread).
await nudge(room_id, proposals.summary_line(proposal), root)
async def handle_query(room_id, root, question):
"""A read-only NL question ('@bot …' / '?…') — translate + run it on the BOX (local Qwen,
nothing leaves the box) and post the answer in a thread. No write path, no approval gate:
it only reads curated, parameterized queries. The endpoint returns its structured result
even on a soft no-match / model-down, so we render that; a transport/auth failure raises
and we show a brief error."""
try:
result = await asyncio.to_thread(crm_client.nl_query, question)
except Exception as exc:
await say(room_id, f"⚠️ couldn't run that query: {str(exc)[:200]}", root)
return
await say(room_id, query.render_answer(result), root)
async def handle_reply(room_id, root, text):
# Claim the proposal synchronously — BEFORE any await — so a second reply that
# arrives while a commit is in flight can't double-process it. asyncio is
# cooperative: nothing else runs between here and the first await below, so the
# pop is atomic w.r.t. other Matrix events.
proposal = store.pop(root)
if proposal is None:
return
if proposal.get("_stage") == "disambiguate":
await handle_disambiguation(room_id, root, text, proposal)
return
action, payload = proposals.interpret_reply(text)
if action == "approve":
try:
summary = await asyncio.to_thread(crm_client.commit, proposal)
except Exception as exc:
store.put(root, proposal) # commit failed — restore so the user can retry
await say(room_id, f"⚠️ write failed, nothing committed: {exc}", root)
return
await say(room_id, f"{summary}", root)
elif action == "reject":
await say(room_id, "🗑️ Discarded — nothing written.", root)
elif action == "edit":
field, value = payload
proposal = proposals.apply_edit(proposal, field, value)
store.put(root, proposal) # keep it pending (edited) for the next reply
await say(room_id, "✏️ Updated:\n\n" + proposals.render(proposal), root)
else:
# Not yes/no/edit-grammar → treat it as a natural-language revision instruction and
# re-run it through local Qwen (no Claude, no scrub). The human still approves the
# revised card, so the draft→approve gate holds.
try:
revised = await asyncio.to_thread(parse.revise, proposal, text, roster=roster)
except Exception as exc:
store.put(root, proposal)
await say(room_id, f"⚠️ couldn't apply that change ({str(exc)[:200]}).\n\nReply **yes** "
"to commit, **no** to discard, **edit field=value**, or rephrase.", root)
return
if proposals.same_fields(proposal, revised):
store.put(root, proposal)
await say(room_id, "I didn't catch a change there. Reply **yes** to commit, **no** "
"to discard, **edit field=value**, or tell me what to change.", root)
return
store.put(root, revised)
await say(room_id, "✏️ Updated:\n\n" + proposals.render(revised), root)
async def handle_disambiguation(room_id, root, text, proposal):
cands = proposal.get("_candidates") or []
action, payload = proposals.interpret_disambiguation(text, len(cands))
if action == "pick":
updated = proposals.attach_to_candidate(proposal, cands[payload])
store.put(root, updated)
await say(room_id, "✏️ Will log against the existing investor:\n\n"
+ proposals.render(updated), root)
elif action == "new":
updated = proposals.promote_to_new(proposal)
store.put(root, updated)
await say(room_id, " OK — adding as a new investor:\n\n"
+ proposals.render(updated), root)
elif action == "reject":
await say(room_id, "🗑️ Discarded — nothing written.", root)
else: # unrecognized — re-show the shortlist
store.put(root, proposal)
await say(room_id, "I didn't catch that.\n\n" + proposals.render_disambiguation(proposal), root)
async def redact_card(event_id):
"""Redact one event (best-effort). Redacting our OWN message needs no special power;
redacting someone else's reply needs the bot to hold a redact/mod power level."""
try:
await client.room_redact(review_room, event_id, reason="proposal resolved")
except Exception as exc:
print(f"matrix-intake: could not redact {event_id}: {exc}", flush=True)
async def redact_thread(root):
"""Clear a resolved thread: redact the card AND every reply under it, so the thread drops
out of the threads view (not just the main timeline). The card is ours (always redactable);
the human's yes/no reply needs the bot's redact/mod power — if it lacks power that redact
just no-ops and the reply lingers. Finds replies by scanning recent room history for
m.thread events pointing at this root (the triggering reply is already synced, so a
backward scan from the current token includes it)."""
await redact_card(root)
token = getattr(client, "next_batch", None)
if not token:
return
try:
scanned = 0
for _ in range(MAX_THREAD_SCAN_PAGES):
resp = await client.room_messages(review_room, start=token,
direction=MessageDirection.back, limit=100)
chunk = getattr(resp, "chunk", None)
if not chunk:
break
for ev in chunk:
rel = ((getattr(ev, "source", None) or {}).get("content", {}) or {}).get("m.relates_to") or {}
if rel.get("rel_type") == "m.thread" and rel.get("event_id") == root:
await redact_card(ev.event_id)
token = getattr(resp, "end", None)
scanned += len(chunk)
if not token or scanned > 1000:
break
except Exception as exc:
print(f"matrix-intake: thread reply cleanup failed for {root}: {exc}", flush=True)
async def handle_email_reply(room_id, root, text):
"""An in-thread reply to a CRM-drafted email-proposal card: yes commits, no dismisses, and
anything else is a natural-language revision of the note (re-drafted by local Qwen; the
human still approves the revised note, so the draft→approve gate holds). On a conclusive
decision the card is redacted so the room clears down to only what still needs handling."""
item = email_threads.get(root)
if item is None:
return # a threaded reply we don't own (or already resolved)
decision = email_proposals.interpret(text)
if decision == "approve":
# Claim before the await (double-approve guard, like the intake commit path).
email_threads.pop(root, None)
try:
await asyncio.to_thread(crm_client.decide_email_proposal, item["id"], "approve", item.get("note"))
except Exception as exc:
email_threads[root] = item # restore for retry
await say(room_id, email_proposals.frame(f"⚠️ couldn't add it ({str(exc)[:200]}). Reply **yes** to retry, **no** to dismiss."), root)
return
# Success → clear the whole thread (card + replies). No confirmation: the thread
# vanishing is the acknowledgment, and a confirmation reply would keep it alive.
await redact_thread(root)
elif decision == "reject":
email_threads.pop(root, None)
try:
await asyncio.to_thread(crm_client.decide_email_proposal, item["id"], "dismiss")
except Exception as exc:
email_threads[root] = item
await say(room_id, email_proposals.frame(f"⚠️ couldn't dismiss it ({str(exc)[:200]}). Try again."), root)
return
await redact_thread(root)
else:
try:
new_note = await asyncio.to_thread(email_proposals.revise_note, item.get("note") or "", text)
except Exception as exc:
await say(room_id, email_proposals.frame(f"⚠️ couldn't revise that ({str(exc)[:200]}). Reply **yes** to add as-is, "
"**no** to dismiss, or rephrase."), root)
return
if not new_note:
await say(room_id, email_proposals.frame("I didn't catch a change. Reply **yes** to add the note as-is, **no** to "
"dismiss, or tell me how to change it."), root)
return
item["note"] = new_note
email_threads[root] = item
await say(room_id, email_proposals.frame(f"✏️ Updated draft note:\n\n{new_note}\n\nReply **yes** to add it, **no** to "
"dismiss, or refine again."), root)
async def poll_email_proposals():
"""Poll the CRM for email-activity proposals: post a review card for each new one, rebuild
the reply-routing map from already-posted threads (so replies still route after a restart),
and announce+close any decided on the web. One failing cycle logs and retries next tick."""
while True:
try:
lists = await asyncio.to_thread(crm_client.list_email_proposals)
for it in lists["open"]: # rebuild routing for threads posted before (e.g. a restart)
ev = it.get("event_id")
if ev and ev not in email_threads:
email_threads[ev] = {"id": it["id"], "investor_name": it.get("investor_name"),
"note": it.get("proposed_note") or ""}
for it in lists["to_post"]:
try:
resp = await client.room_send(
review_room, "m.room.message",
matrix_io.thread_content(email_proposals.render_card(it), None))
ev = getattr(resp, "event_id", None)
if not ev:
print(f"matrix-intake: card send returned no event_id for {it['id']}", flush=True)
continue
await asyncio.to_thread(crm_client.mark_email_proposal_posted, it["id"], ev)
email_threads[ev] = {"id": it["id"], "investor_name": it.get("investor_name"),
"note": it.get("proposed_note") or ""}
except Exception as exc:
print(f"matrix-intake: failed to post email proposal {it.get('id')}: {exc}", flush=True)
for it in lists["to_close"]: # decided on the web → clear the thread, then close
ev = it.get("event_id")
if not ev:
continue
try:
await redact_thread(ev)
await asyncio.to_thread(crm_client.mark_email_proposal_closed, it["id"])
email_threads.pop(ev, None)
except Exception as exc:
print(f"matrix-intake: failed to close email proposal {it.get('id')}: {exc}", flush=True)
except Exception as exc:
print(f"matrix-intake: email-proposal poll error: {exc}", flush=True)
await asyncio.sleep(EMAIL_POLL_SEC)
async def on_message(room: MatrixRoom, event: RoomMessageText):
if event.sender == mx["user_id"]:
return # never react to our own messages (we post in-thread — this prevents loops)
text = (event.body or "").strip()
if not text:
return
root = matrix_io.thread_root_of(event)
# Email-proposal review room: only a threaded reply to a card we posted is actionable.
if review_room and room.room_id == review_room:
if root and root in email_threads:
await handle_email_reply(room.room_id, root, text)
return
# Dedicated Q&A room: every top-level message IS a question — no trigger needed. Threaded
# messages (the answers we post, or follow-ups) aren't acted on in v1.
if query_room and room.room_id == query_room:
if not root:
await handle_query(room.room_id, event.event_id, text)
return
if room.room_id != intake_room:
return
if root and store.has(root):
await handle_reply(room.room_id, root, text)
elif root:
return # threaded message not tied to a live proposal — ignore
else:
# A top-level message is either an NL question (explicitly addressed with '?'/'@bot')
# or an intake note. The trigger is required, so plain notes still flow to intake.
q = query.parse_trigger(text)
if q is None:
await handle_intake(room.room_id, event.event_id, text)
elif not q:
await say(room.room_id, query.HELP, event.event_id)
else:
await handle_query(room.room_id, event.event_id, q)
# Prime the sync token past history, THEN register the callback — only react to messages
# arriving after startup (no backlog replay). (matrix-bridge pattern.)
print("matrix-intake: priming sync (skipping backlog)...", flush=True)
await client.sync(timeout=30000, full_state=False)
client.add_event_callback(on_message, RoomMessageText)
who = await client.whoami()
print(f"matrix-intake: listening as {who.user_id} in room {intake_room}", flush=True)
tasks = [asyncio.create_task(client.sync_forever(timeout=30000))]
if review_room:
# "Invited" isn't "joined" — the bot must join before it can post cards (room_send to a
# room we're only invited to fails M_FORBIDDEN). Idempotent if already a member.
try:
await client.join(review_room)
except Exception as exc:
print(f"matrix-intake: could not join review room {review_room}: {exc}", flush=True)
tasks.append(asyncio.create_task(poll_email_proposals()))
print(f"matrix-intake: reviewing email proposals in room {review_room} (every {EMAIL_POLL_SEC}s)", flush=True)
if query_room:
# Read-only Q&A room — just join and listen (no poll task; questions are interactive).
# "Invited" isn't "joined": the bot must join before it can post answers (idempotent).
try:
await client.join(query_room)
except Exception as exc:
print(f"matrix-intake: could not join Q&A room {query_room}: {exc}", flush=True)
print(f"matrix-intake: answering questions in room {query_room}", flush=True)
try:
await asyncio.gather(*tasks)
finally:
await client.close()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+199
View File
@@ -0,0 +1,199 @@
"""CRM API client for the intake bot's write-back leg.
The bot authenticates as a dedicated service user (Bearer JWT via /api/auth/login — the CRM
has no service-key path) and reuses the CRM's OWN canonical write endpoint
(/api/fundraising/log-communication) for both new-investor and existing-note cases, rather
than mutating the grid itself. That endpoint creates the grid row (create_investor_if_missing),
upserts the contact, logs the communication, appends a visible note, and re-syncs the
relational tables + audit — exactly as a UI grid edit would. We only tag provenance
(source="matrix_intake"). The payload builder is a pure function so it's unit-tested offline.
"""
import json
import ssl
import urllib.error
import urllib.request
from urllib.parse import urlencode
import settings
_token = None
def _http(method, path, body=None, token=None):
s = settings.crm_settings()
url = s["base"] + path
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, data=data, method=method, headers=headers)
ctx = None
if url.lower().startswith("https") and not s["verify_tls"]:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as exc:
raw = exc.read()
try:
payload = json.loads(raw) if raw else {}
except Exception:
payload = {"raw": raw.decode("utf-8", "replace")}
return exc.code, payload
def _login():
global _token
s = settings.crm_settings()
if not s["username"] or not s["password"]:
raise RuntimeError("CRM bot creds not set (CRM_BOT_USERNAME / CRM_BOT_PASSWORD)")
status, data = _http("POST", "/api/auth/login",
{"username": s["username"], "password": s["password"]})
if status != 200 or not data.get("token"):
raise RuntimeError(f"CRM login failed ({status}): {data.get('error') or data}")
_token = data["token"]
return _token
def _authed(method, path, body=None):
"""Call the CRM with the cached token; re-login once on a 401 (token expiry)."""
global _token
token = _token or _login()
status, data = _http(method, path, body, token=token)
if status == 401:
token = _login()
status, data = _http(method, path, body, token=token)
return status, data
def match(proposal):
"""Resolve new-vs-existing for this proposal against the CRM matcher.
Returns {'match': {...}|None, 'candidates': [...]}:
- `match` is a confident EXACT existing investor — {'id', 'name'} — that the bot
auto-attaches a note to (no human disambiguation needed).
- `candidates` is a ranked list of fuzzy NEAR-matches — each {'id', 'name', 'score',
'matched_on'} — surfaced in-thread for the human to pick from (or confirm "new")
when there is no exact match, so a typo'd/near-duplicate name doesn't silently
create a second investor."""
q = proposal.get("investor_name") or proposal.get("contact_name") or ""
email = proposal.get("contact_email") or ""
if not q and not email:
return {"match": None, "candidates": []}
qs = urlencode({"q": q, "email": email})
status, data = _authed("GET", f"/api/intake/match?{qs}")
if status != 200:
raise RuntimeError(f"intake match failed ({status}): {data.get('error') or data}")
payload = data.get("data") or {}
m = payload.get("match")
match_out = {"id": m["id"], "name": m.get("investor_name") or q} if m else None
candidates = [
{"id": c["id"], "name": c.get("investor_name") or "?",
"score": c.get("score"), "matched_on": c.get("matched_on")}
for c in (payload.get("candidates") or []) if c.get("id")
]
return {"match": match_out, "candidates": candidates}
def list_email_proposals():
"""Pull the email-activity review work-lists for the poll loop: {to_post, open, to_close}.
to_post = pending, un-posted (post a card); open = posted, awaiting a decision (rebuild the
reply-routing map after a restart); to_close = decided on the web (announce in-thread + close)."""
status, data = _authed("GET", "/api/intake/email-proposals")
if status != 200:
raise RuntimeError(f"email-proposals list failed ({status}): {data.get('error') or data}")
payload = data.get("data") or {}
return {k: (payload.get(k) or []) for k in ("to_post", "open", "to_close")}
def mark_email_proposal_posted(proposal_id, event_id):
"""Record the Matrix thread-root event id so the proposal's review state survives a restart."""
status, data = _authed("POST", f"/api/intake/email-proposals/{proposal_id}/matrix",
{"event_id": event_id})
if status != 200:
raise RuntimeError(f"mark posted failed ({status}): {data.get('error') or data}")
return data.get("data") or {}
def mark_email_proposal_closed(proposal_id):
"""Mark the review thread resolved after announcing a web-side decision in it."""
status, data = _authed("POST", f"/api/intake/email-proposals/{proposal_id}/matrix",
{"closed": True})
if status != 200:
raise RuntimeError(f"mark closed failed ({status}): {data.get('error') or data}")
return data.get("data") or {}
def decide_email_proposal(proposal_id, decision, note=None):
"""Relay an in-thread approve/dismiss (with the possibly-revised note) to the CRM. The server
appends the note to the grid on approve, tags source='matrix', and closes the thread."""
body = {"decision": decision}
if note is not None:
body["note"] = note
status, data = _authed("POST", f"/api/intake/email-proposals/{proposal_id}/decide", body)
if status not in (200, 201):
raise RuntimeError(f"email-proposal decide failed ({status}): {data.get('error') or data}")
return data.get("data") or {}
def nl_query(question):
"""Ask the read-only NL-query endpoint (POST /api/query/nl). Translation runs on the box's
LOCAL model — the question never leaves the box and no write is possible. Returns the
endpoint's structured result dict ({intent, slots, rows, summary, ...} or {error, detail});
the server returns that same body on a hit AND on the soft 503 (model down) / 500 (query
fault) status codes, so we hand it straight to the renderer. Any OTHER status — auth (403),
a malformed request (400), an unexpected shape — raises so the caller posts a brief error."""
status, data = _authed("POST", "/api/query/nl", {"question": question, "source": "matrix"})
if status not in (200, 500, 503):
raise RuntimeError(f"nl-query failed ({status}): {data.get('error') or data}")
return data.get("data") or {}
def build_commit_payload(proposal):
"""Pure: map a proposal to the /api/fundraising/log-communication request body.
Existing investor (carries _match_id) → target that exact grid row. Otherwise create the
investor if missing. The note becomes the communication body; the email is only sent when
it survived parse's source-text integrity check."""
contact = {
"name": proposal.get("contact_name") or proposal.get("investor_name") or "",
"email": proposal.get("contact_email") or "",
"title": proposal.get("contact_title") or "",
}
note = proposal.get("note") or ""
# The CRM's grid note line uses subject-or-body for its one-line summary, so a non-empty
# subject hides the actual note text. Send a blank subject when there's a note (let the note
# itself show in the grid); fall back to a provenance label only when there's nothing to
# show. Provenance is recorded via source="matrix_intake" either way.
intent_label = "Note (Matrix)" if proposal.get("intent") == "meeting_note" else "Intake (Matrix)"
payload = {
"contact": contact,
"type": "note",
"body": note,
"subject": "" if note.strip() else intent_label,
"append_note": True,
"source": "matrix_intake",
}
match_id = proposal.get("_match_id")
if match_id:
payload["row_id"] = match_id
else:
payload["investor_name"] = proposal.get("investor_name") or proposal.get("contact_name") or ""
payload["create_investor_if_missing"] = True
return payload
def commit(proposal):
"""Write the approved proposal to the CRM; return a short human summary for the thread."""
payload = build_commit_payload(proposal)
status, data = _authed("POST", "/api/fundraising/log-communication", payload)
if status not in (200, 201):
raise RuntimeError(f"log-communication failed ({status}): {data.get('error') or data}")
row = (data.get("data") or {}).get("row") or {}
name = row.get("investor_name") or payload.get("investor_name") or "investor"
if proposal.get("_match_id"):
return f"Logged a note on **{name}** (existing grid entry)."
return f"Created a new grid entry for **{name}**" + (" and logged a note." if payload.get("body") else ".")
+84
View File
@@ -0,0 +1,84 @@
"""Email-activity proposal review over Matrix — the CRM→Matrix leg of the email-capture flow.
The CRM (on the box) drafts a proposed grid note per newly-matched email (local model, no Claude)
and queues it for human review. The CRM is stdlib-only and can't post to Matrix itself, so this
bot PULLS the pending proposals (crm_client.list_email_proposals), posts a review card to the
dedicated review room, and relays the human's in-thread reply back to the CRM. Same draft→approve
discipline as the intake bot: nothing is appended to the grid until a human approves — here OR on
the web Email Capture panel, the two surfaces kept in sync via the CRM's email_proposal_matrix row.
This module is the PURE logic (card rendering, reply grammar, note revision) so it's unit-tested
offline; the async poll/post/reply wiring lives in bot.py (network + Matrix, live-smoke only).
"""
import spark
_YES = {"yes", "y", "approve", "approved", "ok", "confirm", "go", "add", "👍", ""}
_NO = {"no", "n", "cancel", "discard", "reject", "skip", "stop", "👎", ""}
_SNIPPET_MAX = 400 # email snippet shown on the card; the full body is in the web popup
RULE = "-----------------------" # top/bottom rule so threads don't bleed together on mobile
def frame(text):
"""Wrap a message in dash rules so each card/reply is visually bounded in the room."""
return f"{RULE}\n{text}\n{RULE}"
def _truncate(s, n):
s = (s or "").strip()
return s if len(s) <= n else s[:n].rstrip() + ""
def render_card(item):
"""The review card posted to the Matrix review room: who/when + a short email snippet + the
drafted note. Deliberately compact for mobile — the full scrollable body is in the web Email
Capture popup. Direction isn't a bare label anymore — the note itself names who emailed whom."""
name = item.get("investor_name") or "Unknown investor"
frm = item.get("from_name") or item.get("from_email") or "?"
lines = [f"📧 Proposed **grid note** for **{name}**"]
if item.get("email_subject"):
lines.append(f"· Subject: {item['email_subject']}")
if item.get("email_date"):
lines.append(f"· Date: {item['email_date']}")
lines.append(f"· From: {frm}")
snippet = _truncate(item.get("snippet"), _SNIPPET_MAX)
if snippet:
lines.append(f"· Email: {snippet}")
lines.append("")
lines.append(f"📝 Draft note: {item.get('proposed_note') or '(empty)'}")
lines.append("")
lines.append("Reply **yes** to add it to the grid, **no** to dismiss, or just tell me how to "
"change the note (e.g. *say we discussed the Q3 raise*).")
return frame("\n".join(lines))
def interpret(text):
"""Classify an in-thread reply: 'approve' | 'reject' | 'revise' (anything else → revise the note)."""
t = (text or "").strip().lower()
if t in _YES:
return "approve"
if t in _NO:
return "reject"
return "revise"
REVISE_SYSTEM = (
"You revise a single CRM note from a short instruction a venture-fund team member typed. "
"You are given the CURRENT note and an INSTRUCTION. Apply the instruction and reply with "
"ONLY a JSON object of the form {\"note\": \"<the full revised note>\"}. Keep it to one or two "
"factual sentences, no preamble. Output JSON only."
)
def revise_note(note, instruction, parse_fn=spark.parse_json):
"""Re-draft the note via local Qwen from a free-form instruction (no Claude, no scrub — same
local-only basis as the intake parse). Returns the new note text, or None if the model gave
nothing usable / unchanged, in which case the caller re-prompts. `parse_fn` is injectable for
tests."""
prompt = "CURRENT:\n" + (note or "") + "\n\nINSTRUCTION:\n" + (instruction or "").strip()
raw = parse_fn(prompt, system=REVISE_SYSTEM, max_tokens=400) or {}
new = raw.get("note") if isinstance(raw, dict) else None
new = (new or "").strip()
if not new or new == (note or "").strip():
return None
return new
+73
View File
@@ -0,0 +1,73 @@
"""Matrix plumbing lifted from matrix-bridge (src/bot.py): message splitting, thread-root
detection, and a threaded-reply sender. Kept dependency-light so the rest of the bot is
testable without a live homeserver."""
MAX_MSG_CHARS = 30000 # well under Matrix's ~64KB event cap
def split_message(text, limit=MAX_MSG_CHARS):
"""Split text into <=limit-char chunks on newline boundaries (no truncation)."""
if len(text) <= limit:
return [text]
chunks, buf = [], ""
for line in text.splitlines(keepends=True):
while len(line) > limit:
if buf:
chunks.append(buf)
buf = ""
chunks.append(line[:limit])
line = line[limit:]
if len(buf) + len(line) > limit:
chunks.append(buf)
buf = ""
buf += line
if buf:
chunks.append(buf)
return chunks
def thread_root_of(event):
"""Return the thread root event_id if this message is a threaded reply, else None."""
relates = (getattr(event, "source", None) or {}).get("content", {}).get("m.relates_to") or {}
if relates.get("rel_type") == "m.thread":
return relates.get("event_id")
return None
def thread_content(text, thread_root):
"""Build an m.room.message content dict, threaded under thread_root when given."""
content = {"msgtype": "m.text", "body": text}
if thread_root:
content["m.relates_to"] = {
"rel_type": "m.thread",
"event_id": thread_root,
"is_falling_back": True,
"m.in_reply_to": {"event_id": thread_root},
}
return content
def make_say(client):
"""Return an async say(room_id, text, thread_root=None) bound to a matrix-nio client."""
async def say(room_id, text, thread_root=None):
for chunk in split_message(text):
await client.room_send(room_id, "m.room.message", thread_content(chunk, thread_root))
return say
def reply_content(text, reply_to_event_id):
"""Build a plain (non-threaded) reply: shows in the MAIN timeline as a reply to
reply_to_event_id, unlike thread_content() which lands the message inside a thread."""
content = {"msgtype": "m.text", "body": text}
if reply_to_event_id:
content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_event_id}}
return content
def make_reply(client):
"""Return an async reply(room_id, text, reply_to) that posts a plain main-timeline reply —
the brief 'proposed X — see thread' nudge alongside the in-thread proposal card."""
async def reply(room_id, text, reply_to):
for chunk in split_message(text):
await client.room_send(room_id, "m.room.message", reply_content(chunk, reply_to))
return reply
+145
View File
@@ -0,0 +1,145 @@
"""Turn a free-text intake message into a normalized proposal via local Qwen.
The model only EXTRACTS structure; it never decides to write anything. New-vs-existing is
finalized in M2 against the CRM matcher — here `intent` is the model's first read.
`revise()` is the conversational-edit leg: a free-form correction the human types in the
proposal thread (e.g. "add that we met June 14") is applied to the pending proposal via the
same local Qwen — no Claude, no scrub. Email integrity is preserved: a changed address must
literally appear in the instruction (or the original message); the model can never mint one.
"""
import json
import re
import spark
SYSTEM = (
"You extract structured investor-intake data from a short message a venture-fund "
"team member typed about their fundraising outreach. The message is a note FROM a "
"team member ABOUT an investor or prospect they are contacting. Reply with ONLY a JSON "
"object, no prose, with these keys:\n"
' "intent": "new_investor" if the message introduces a new investor or prospect, '
'"meeting_note" if it logs a note/update about an investor, else "unclear".\n'
' "investor_name": the investing firm or entity name (e.g. "Acme Capital"), or null.\n'
' "contact_name": the individual person mentioned, or null.\n'
' "contact_email": the person\'s email if explicitly present, else null. Never invent one.\n'
' "contact_title": the person\'s role/title if stated, else null.\n'
' "note": any meeting note, context, or next step, else null.\n'
"Use null (not empty string) for anything not present."
)
# Appended when the team roster is known, so the model reads a teammate's name as the person
# DOING the outreach, not the investor — fixes "Jonathan is chatting with Wyoming" extracting
# the teammate instead of the prospect. Names come from settings.team_roster() (INTAKE_TEAM_ROSTER).
ROSTER_FRAME = (
"These names and initials (case-insensitive) are our OWN team members — the people doing "
"the outreach, NOT investors or prospects. Never extract one as investor_name or "
"contact_name: {names}. When a team member is described talking with, meeting, or chasing "
'someone (e.g. "Jonathan is chatting with Wyoming"), the OTHER party (here "Wyoming") is '
"the investor or prospect to extract."
)
def build_system(roster=None, base=SYSTEM):
"""Assemble the extraction system prompt. With a `roster` (team-member names) it appends
the outreach frame so a teammate's name is read as the person doing outreach, not the
investor. JSON-only stays the last line for recency. Pure + offline-testable."""
parts = [base]
if roster:
parts.append(ROSTER_FRAME.format(names=", ".join(roster)))
parts.append("Output JSON only.")
return "\n".join(parts)
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
_VALID_INTENTS = {"new_investor", "meeting_note", "unclear"}
_FIELDS = ("intent", "investor_name", "contact_name", "contact_email", "contact_title", "note")
def _clean(v):
if v is None:
return None
s = str(v).strip()
if not s or s.lower() in ("null", "none", "n/a", "na", "unknown"):
return None
return s
def normalize(raw, source_text=""):
"""Coerce the model's dict into a stable proposal shape; salvage an email from the
source text if the model missed one. Returns a dict with all _FIELDS keys."""
raw = raw or {}
out = {k: _clean(raw.get(k)) for k in _FIELDS}
intent = (out["intent"] or "").lower().replace("-", "_").replace(" ", "_")
out["intent"] = intent if intent in _VALID_INTENTS else "unclear"
# Email integrity: only accept an address that literally appears in the source message.
# The model is unreliable for verbatim strings and must never mint an address — anything
# not present in what the human typed is dropped (a wrong email in the CRM is worse than
# none). This both salvages a missed address and rejects a hallucinated one.
m = _EMAIL_RE.search(source_text or "")
out["contact_email"] = m.group(0).rstrip(".,;:!?)]}>\"'") if m else None
# An intake with no firm AND no person is not actionable.
if not out["investor_name"] and not out["contact_name"]:
out["intent"] = "unclear"
return out
def parse_message(text, parse_fn=spark.parse_json, roster=None):
"""Parse one intake message. `parse_fn` is injectable for tests (defaults to Spark/Qwen);
`roster` is the team-member names that frame the extraction (see build_system).
Returns a normalized proposal dict. On a model/transport failure, raises (caller decides)."""
raw = parse_fn(text, system=build_system(roster), max_tokens=400)
proposal = normalize(raw, source_text=text)
# Stash the original message so a later revise() can re-check email integrity against it.
proposal["_source_text"] = text
return proposal
REVISE_SYSTEM = (
"You revise a structured investor-intake proposal from a short correction a venture-fund "
"team member typed. You are given the CURRENT proposal as JSON and an INSTRUCTION. Apply "
"the instruction and reply with ONLY the full revised JSON object, these keys:\n"
' "investor_name", "contact_name", "contact_email", "contact_title", "note".\n'
"Change ONLY what the instruction asks; copy every other field through unchanged. Use null "
"for a field the instruction clears or that is genuinely absent. Never invent an email "
"address."
)
_REVISABLE = ("investor_name", "contact_name", "contact_title", "note")
def _apply_revision(proposal, model_out, instruction):
"""Merge the model's revised fields onto the proposal. Pure + offline-testable.
Preserves control keys (_match_id / _stage / intent / _source_text). Enforces email
integrity: a revised address is taken only if it literally appears in the INSTRUCTION the
human typed; otherwise the existing (already integrity-checked) address is kept. The model's
own email field is never trusted — it must not mint an address."""
model_out = model_out or {}
out = dict(proposal)
for k in _REVISABLE:
if k in model_out:
out[k] = _clean(model_out.get(k))
m = _EMAIL_RE.search(instruction or "")
if m:
out["contact_email"] = m.group(0).rstrip(".,;:!?)]}>\"'")
# else: keep proposal's current contact_email (untouched above; control key copied by dict())
# Don't let a revision strip the proposal down to nothing actionable.
if not out.get("investor_name") and not out.get("contact_name"):
out["investor_name"] = proposal.get("investor_name")
out["contact_name"] = proposal.get("contact_name")
return out
def revise(proposal, instruction, parse_fn=spark.parse_json, roster=None):
"""Apply a natural-language correction to a pending proposal via local Qwen; return the
revised proposal dict. `parse_fn` is injectable for tests (defaults to Spark/Qwen);
`roster` frames the revision the same way parse_message does (see build_system)."""
current = {k: proposal.get(k) for k in
("investor_name", "contact_name", "contact_email", "contact_title", "note")}
prompt = ("CURRENT:\n" + json.dumps(current, ensure_ascii=False)
+ "\n\nINSTRUCTION:\n" + (instruction or "").strip())
raw = parse_fn(prompt, system=build_system(roster, base=REVISE_SYSTEM), max_tokens=400)
return _apply_revision(proposal, raw, instruction)
+192
View File
@@ -0,0 +1,192 @@
"""Pending-proposal store + the in-thread approval state machine.
The one piece of state in the bot: a proposal awaiting a human's yes/edit/no, keyed by the
Matrix thread root (the bot's proposal lives in a thread rooted at the user's message, and
the user replies inside that thread). In-memory and ephemeral by design — a restart drops
pending proposals (the user just re-sends), matching matrix-bridge's stateless-by-default
ethos. Nothing here writes to the CRM; the bot calls the CRM client only after `approve`.
A proposal carries a `_stage`: "approval" (the normal yes/edit/no card) or "disambiguate"
(a fuzzy-match shortlist the human must resolve — pick a number / "new" / "no" — before it
becomes an approval-stage proposal). The shortlist itself rides on `_candidates`.
"""
import re
# field aliases accepted in `edit <field>=<value>`
_EDIT_ALIASES = {
"name": "investor_name", "investor": "investor_name", "firm": "investor_name", "org": "investor_name",
"contact": "contact_name", "person": "contact_name",
"email": "contact_email",
"title": "contact_title", "role": "contact_title",
"note": "note",
}
_YES = {"yes", "y", "approve", "approved", "ok", "confirm", "go", "👍", ""}
_NO = {"no", "n", "cancel", "discard", "reject", "stop", "👎", ""}
# "create a new investor anyway" replies to a disambiguation shortlist
_NEW = {"new", "none", "new investor", "none of these", "create", "create new", "add new", "neither"}
_CONTENT_FIELDS = ("intent", "investor_name", "contact_name", "contact_email", "contact_title", "note")
class ProposalStore:
def __init__(self):
self._pending = {} # thread_root -> proposal dict
def put(self, thread_root, proposal):
self._pending[thread_root] = proposal
def get(self, thread_root):
return self._pending.get(thread_root)
def pop(self, thread_root):
return self._pending.pop(thread_root, None)
def has(self, thread_root):
return thread_root in self._pending
def any_pending(self):
return bool(self._pending)
def _parse_edit(text):
"""Parse 'edit field=value' (also 'field: value'); return (canonical_field, value) or None."""
body = text.strip()
if body.lower().startswith("edit "):
body = body[5:].strip()
for sep in ("=", ":"):
if sep in body:
field, value = body.split(sep, 1)
field = field.strip().lower()
canon = _EDIT_ALIASES.get(field)
value = value.strip()
if canon and value:
return canon, value
# Not a known field on this separator — try the next one rather than bail,
# so e.g. "note: see deck=v2" still parses (split on ':' not the inner '=').
continue
return None
def interpret_reply(text):
"""Classify a threaded reply to a pending proposal.
Returns one of:
("approve", None) | ("reject", None) | ("edit", (field, value)) | ("unknown", None)
"""
t = (text or "").strip()
low = t.lower()
if low in _YES:
return ("approve", None)
if low in _NO:
return ("reject", None)
edit = _parse_edit(t)
if edit:
return ("edit", edit)
return ("unknown", None)
def apply_edit(proposal, field, value):
"""Return a copy of the proposal with one field changed."""
updated = dict(proposal)
updated[field] = value
return updated
def same_fields(a, b):
"""True if two proposals carry identical content (used to detect a no-op NL revision so we
don't tell the human 'Updated' when nothing changed)."""
return all((a or {}).get(k) == (b or {}).get(k) for k in _CONTENT_FIELDS)
def interpret_disambiguation(text, n_candidates):
"""Classify a reply to a fuzzy-match shortlist.
Returns ("pick", index) | ("new", None) | ("reject", None) | ("unknown", None). A bare
number selects that candidate; "new"/"none" creates a new investor; "no"/"cancel" discards."""
t = (text or "").strip().lower()
if not t:
return ("unknown", None)
if t in _NO:
return ("reject", None)
if t in _NEW:
return ("new", None)
m = re.fullmatch(r"#?\s*(\d{1,2})", t)
if m:
idx = int(m.group(1)) - 1
if 0 <= idx < n_candidates:
return ("pick", idx)
return ("unknown", None)
def attach_to_candidate(proposal, candidate):
"""Promote a disambiguation pick into an approval-stage meeting note on the chosen investor.
The note will target that existing grid row (via _match_id); the firm name is shown for
accuracy. Drops the shortlist."""
updated = dict(proposal)
updated.pop("_candidates", None)
updated["_stage"] = "approval"
updated["_match_id"] = candidate["id"]
updated["intent"] = "meeting_note"
if candidate.get("name"):
updated["investor_name"] = candidate["name"]
return updated
def promote_to_new(proposal):
"""Disambiguation 'new' — discard the shortlist and proceed as a new-investor proposal."""
updated = dict(proposal)
updated.pop("_candidates", None)
updated.pop("_match_id", None)
updated["_stage"] = "approval"
return updated
def render_disambiguation(proposal):
"""Render the fuzzy-match shortlist a human resolves before we create a new investor."""
name = proposal.get("investor_name") or proposal.get("contact_name") or "?"
cands = proposal.get("_candidates") or []
lines = [f"🔎 Before adding **{name}** as new — these existing investors look similar:"]
for i, c in enumerate(cands, 1):
lines.append(f" **{i}.** {c.get('name') or '?'}")
lines.append("")
lines.append("Reply a **number** to log this against that investor, **new** to add it as a "
"new investor, or **no** to discard.")
return "\n".join(lines)
def disambiguation_nudge(proposal):
"""Brief main-timeline pointer for a disambiguation proposal (the shortlist is in the thread)."""
name = proposal.get("investor_name") or proposal.get("contact_name") or "?"
return (f"🔎 **{name}** may match an existing investor — open the **thread** to pick one "
"or confirm it's new.")
def render(proposal):
"""Render a proposal as the in-thread message a human approves."""
if proposal.get("intent") == "meeting_note":
head = f"📝 Proposed **meeting note** for **{proposal.get('investor_name') or proposal.get('contact_name') or '?'}**"
else:
head = f"📇 Proposed **new investor**: **{proposal.get('investor_name') or proposal.get('contact_name') or '?'}**"
lines = [head]
fields = [
("Investor", proposal.get("investor_name")),
("Contact", proposal.get("contact_name")),
("Email", proposal.get("contact_email")),
("Title", proposal.get("contact_title")),
("Note", proposal.get("note")),
]
for label, val in fields:
if val:
lines.append(f"· {label}: {val}")
lines.append("")
lines.append("Reply **yes** to commit, **edit field=value** to change a field, or **no** to discard.")
return "\n".join(lines)
def summary_line(proposal):
"""A brief one-liner for the main-timeline nudge; the full card lives in the thread."""
name = proposal.get("investor_name") or proposal.get("contact_name") or "?"
if proposal.get("intent") == "meeting_note":
return f"📝 Proposed a meeting note for **{name}** — see the thread to review & approve."
return f"📇 Proposed a new investor: **{name}** — see the thread to review & approve."
+189
View File
@@ -0,0 +1,189 @@
"""NL-query Matrix surface (W2 step 5) — turn an '@bot <question>' message into a read-only
answer from the CRM's curated NL-query endpoint, and render that answer for the chat room.
This module is PURE (no network, no matrix-nio) so it's unit-testable offline; the async wiring
(call the endpoint, post in a thread) lives in bot.py. The endpoint does the real work:
translation runs on the box's LOCAL model (nothing leaves the box) and only the curated,
parameterized queries can run — there is no write path here, so no approval gate applies.
Trigger: a top-level message starting with '?' / '@bot' / '/ask' (see parse_trigger). We
deliberately do NOT accept a bare leading 'ask', which would collide with intake notes like
"Ask Jane to send the Q3 deck".
"""
# Markers a human wouldn't start an intake note with. '?' is handled separately (single char).
QUERY_PREFIXES = ("@bot", "/ask", "/query", "/q")
# Soft cap on rows rendered into a single chat answer. The endpoint already caps the SQL result
# (server MAX_ROWS), but 500 rows is unreadable on mobile — show the first N and say how many
# more there are (never a silent cut). Refine the question or use the web Ask box for the rest.
MAX_DISPLAY_ROWS = 30
# Column-name hints used only for nicer formatting (money / dates). Cosmetic — never affects
# what's queried (that's fixed in intents.py).
_MONEY_HINTS = ("amount", "invested", "total", "expected", "committed")
# 0/1 flag columns: suppress when 0 (noise), show a label when 1.
_FLAG_LABELS = {"graveyard": "retired", "overdue": "⚠️ overdue"}
def parse_trigger(text):
"""If `text` is addressed to the query bot, return the question (the remainder after the
trigger, possibly an empty string when the trigger is bare). Return None if it isn't a query,
so the caller routes it to intake instead."""
s = (text or "").strip()
if not s:
return None
if s[0] == "?":
return s[1:].strip()
low = s.lower()
for p in QUERY_PREFIXES:
if low.startswith(p):
rest = s[len(p):]
# Require a separator so '/asking …' isn't read as the '/ask' trigger.
if rest == "" or rest[0] in " \t\n:,":
return rest.lstrip(" \t\n:,").strip()
return None
def _examples():
return ("Try things like:\n"
"• `?which investors haven't we contacted in 90 days?`\n"
"• `?top 10 investors by committed capital`\n"
"• `?when did we last reach out to Acme Capital?`\n"
"• `?how many emails has Grant sent this month?`")
HELP = ("💬 Ask me about the fundraising database — start your message with `?` (or `@bot`).\n\n"
+ _examples())
def _is_money_col(name):
n = name.lower()
return any(h in n for h in _MONEY_HINTS)
def _fmt_value(col, val):
"""Format one scalar cell for chat: dates -> YYYY-MM-DD, money columns -> $1,234, else str."""
if val is None:
return ""
name = col.lower()
if name.endswith("_at") or name.endswith("date"):
return str(val)[:10]
if isinstance(val, (int, float)) and _is_money_col(col):
return f"${val:,.0f}"
return str(val)
def _render_contacts(contacts):
"""investor_lookup's nested contact dicts -> 'Name <email> (title · city, state)' lines."""
out = []
for c in contacts:
bits = c.get("full_name") or "?"
if c.get("email"):
bits += f" <{c['email']}>"
loc = ", ".join(x for x in (c.get("city"), c.get("state"), c.get("country")) if x)
extra = " · ".join(x for x in (c.get("title"), loc) if x)
if extra:
bits += f" ({extra})"
out.append(bits)
return out
def _render_commitments(commitments):
"""investor_lookup's nested per-fund commitments -> 'Fund: $amount' lines."""
out = []
for c in commitments:
fund = c.get("fund_name") or "?"
amt = c.get("amount")
out.append(f"{fund}: ${amt:,.0f}" if isinstance(amt, (int, float)) else f"{fund}: {amt}")
return out
def _render_row(i, row, columns):
cols = columns or list(row.keys())
lead = None
scalars = []
sublines = []
for col in cols:
val = row.get(col)
if isinstance(val, list):
if not val:
continue
if col == "contacts":
sublines += [f" {x}" for x in _render_contacts(val)]
elif col == "commitments":
sublines += [f" {x}" for x in _render_commitments(val)]
else: # generic list-of-dicts fallback (no intent uses this yet)
sublines += [f" {', '.join(f'{k}={v}' for k, v in d.items())}"
for d in val if isinstance(d, dict)]
continue
if col in _FLAG_LABELS:
if val:
scalars.append(_FLAG_LABELS[col])
continue
s = _fmt_value(col, val)
if s == "":
continue
if lead is None: # first non-empty column is the bold identifier for the row
lead = s
else:
scalars.append(f"{col}: {s}")
head = f"{i}. **{lead}**" if lead else f"{i}."
if scalars:
head += "" + " · ".join(scalars)
return "\n".join([head] + sublines)
def _render_interpretation(intent, slots):
if not intent:
return ""
if slots:
return f"read as: {intent} ({', '.join(f'{k}={v}' for k, v in slots.items())})"
return f"read as: {intent}"
def _render_error(err, result):
detail = (result.get("detail") or "").strip()
if err == "no_match":
return "🤷 I couldn't map that to one of my saved queries.\n\n" + _examples()
if err == "model_unavailable":
return "⚠️ The local query model is unreachable right now — try again in a moment."
if err == "query_failed":
return f"⚠️ That query failed to run{(': ' + detail) if detail else ''}."
# unknown_intent / bad_slot / anything unexpected
return (f"⚠️ I couldn't run that ({err}){(': ' + detail) if detail else ''}.\n\n" + _examples())
def render_answer(result):
"""Render the NL-query endpoint's structured result into a Matrix markdown answer.
`result` is the endpoint body: a hit {intent, slots, columns, rows, summary, truncated} or
an error {error, detail}. Results never go back to any model — this is a deterministic format."""
result = result or {}
err = result.get("error")
if err:
return _render_error(err, result)
summary = (result.get("summary") or "").strip()
rows = result.get("rows") or []
columns = result.get("columns") or []
header = f"📊 {summary}" if summary else "📊 Done."
interp = _render_interpretation(result.get("intent"), result.get("slots") or {})
if interp:
header += f"\n_{interp}_"
if not rows:
return header + "\n\n(no matching records)"
shown = rows[:MAX_DISPLAY_ROWS]
blocks = [_render_row(i + 1, r, columns) for i, r in enumerate(shown)]
out = header + "\n\n" + "\n".join(blocks)
notes = []
extra = len(rows) - len(shown)
if extra > 0:
notes.append(f"+{extra} more not shown")
if result.get("truncated"):
notes.append("results hit the server cap")
if notes:
out += "\n\n_" + "; ".join(notes) + " — refine your question or use the web Ask box._"
return out
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""One-time maintenance: redact already-resolved email-proposal review cards.
The bot redacts a card when it's decided going forward, but cards that were decided BEFORE that
behavior shipped (e.g. smoke-test remnants) are already `closed` in the CRM, so the normal
to_close sweep never touches them. This walks the review room's history, finds the bot's own
"proposed grid note" cards, and redacts every one that is NOT still pending (i.e. not in the CRM
`open` work-list) — leaving the room showing only what still needs handling.
Safe by default: prints what it WOULD redact and does nothing. Pass --apply to actually redact.
Run on the Spark via the bot's own creds/image:
docker compose run --rm matrix-intake python -u backend/matrix_intake/redact_resolved.py
docker compose run --rm matrix-intake python -u backend/matrix_intake/redact_resolved.py --apply
"""
import asyncio
import sys
from nio import AsyncClient, MessageDirection
import crm_client
import settings
CARD_MARKER = "📧 Proposed" # present in every review card (old and dash-framed)
MAX_PAGES = 30 # 30 * 100 events is far more history than this room holds
async def main(apply):
mx = settings.matrix_settings()
review_room = settings.email_review_room()
if not review_room:
print("MATRIX_EMAIL_REVIEW_ROOM is not set — nothing to do.")
return
client = AsyncClient(mx["homeserver"], mx["user_id"])
client.restore_login(user_id=mx["user_id"], device_id=mx["device_id"], access_token=mx["token"])
try:
# Cards still pending (must be KEPT) — their thread-root event id is the card event id.
open_ids = {it["event_id"] for it in crm_client.list_email_proposals().get("open", []) if it.get("event_id")}
print(f"pending cards to keep: {len(open_ids)}")
sync = await client.sync(timeout=10000, full_state=False)
token = sync.next_batch
cards = {} # root event_id -> snippet (still-identifiable card bodies)
replies = {} # reply event_id -> (thread_root, snippet)
for _ in range(MAX_PAGES):
resp = await client.room_messages(review_room, start=token,
direction=MessageDirection.back, limit=100)
chunk = getattr(resp, "chunk", None)
if not chunk:
break
for ev in chunk:
body = (getattr(ev, "body", "") or "").replace("\n", " ")
rel = ((getattr(ev, "source", None) or {}).get("content", {}) or {}).get("m.relates_to") or {}
if rel.get("rel_type") == "m.thread" and rel.get("event_id"):
replies[ev.event_id] = (rel["event_id"], body[:50]) # a threaded reply (card already redacted)
elif getattr(ev, "sender", None) == mx["user_id"] and CARD_MARKER in body:
cards[ev.event_id] = body[:70] # an un-redacted card root
token = getattr(resp, "end", None)
if not token:
break
# Redact card roots that aren't still pending, AND any reply whose thread isn't still pending.
targets = [(eid, "card :: " + snip) for eid, snip in cards.items() if eid not in open_ids]
targets += [(eid, "reply :: " + snip) for eid, (root, snip) in replies.items() if root not in open_ids]
print(f"resolved cards: {sum(1 for e,_ in cards.items() if e not in open_ids)}; "
f"thread replies to clear: {sum(1 for _,(r,_) in replies.items() if r not in open_ids)}")
for eid, label in targets:
print(("APPLY redact " if apply else "WOULD redact ") + eid + " :: " + label)
if apply:
r = await client.room_redact(review_room, eid, reason="retroactive cleanup of resolved review threads")
if not hasattr(r, "event_id"):
print(f" ! redact failed: {r}")
print(("done — redacted " if apply else "dry run — would redact ") + f"{len(targets)} event(s).")
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(main(apply="--apply" in sys.argv[1:]))
+4
View File
@@ -0,0 +1,4 @@
# Matrix intake bot — isolated to this component's own process. matrix-nio is the ONLY
# third-party runtime dep and MUST NOT be added to the stdlib CRM (backend/server.py).
# The Spark/Qwen + CRM-API calls reuse the repo's stdlib HTTP client (backend/ingest/http_util).
matrix-nio>=0.24
+80
View File
@@ -0,0 +1,80 @@
"""Config for the Matrix intake bot — Matrix creds + the dedicated intake room.
Spark settings (SPARK_CONTROL_URL, CHAT_MODEL, …) are NOT read here; they come from the
reused ingest client (see spark.py), which loads the same repo .env. This module only owns
the Matrix connection and the CRM API target for the write-back leg (M2).
"""
import os
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def load_env(path=None):
"""Populate os.environ from the repo .env (setdefault — never clobber a real env var)."""
path = path or os.path.join(REPO_ROOT, ".env")
if not os.path.exists(path):
return
with open(path, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
load_env()
def _require(name):
val = os.environ.get(name, "").strip()
if not val:
raise RuntimeError(f"matrix_intake: required env var {name} is not set (see .env.example)")
return val
# Matrix connection (resolved lazily so importing this module for tests never requires creds).
def matrix_settings():
return {
"homeserver": _require("MATRIX_HOMESERVER"),
"user_id": _require("MATRIX_USER"),
"token": _require("MATRIX_ACCESS_TOKEN"),
"device_id": os.environ.get("MATRIX_DEVICE_ID", "ten31-intake-bot"),
"intake_room": _require("MATRIX_INTAKE_ROOM"),
}
# CRM API target for the write-back leg (M2). The CRM has no service-key auth path — auth is
# Bearer-JWT via /api/auth/login — so the bot logs in as a DEDICATED service user (a normal
# CRM user, created by an admin) and reuses the existing auth. Creds live in .env, never code.
def crm_settings():
return {
"base": os.environ.get("CRM_API_BASE", "http://127.0.0.1:8080").rstrip("/"),
"username": os.environ.get("CRM_BOT_USERNAME", "").strip(),
"password": os.environ.get("CRM_BOT_PASSWORD", ""),
"verify_tls": os.environ.get("CRM_API_VERIFY_TLS", "true").lower() in ("1", "true", "yes", "on"),
}
# Team-member names (comma-separated in INTAKE_TEAM_ROSTER), fed to the parser so a teammate's
# name reads as the person DOING outreach, not the investor (see parse.build_system). Optional —
# unset/empty just means no roster framing, i.e. the prior behavior.
def team_roster():
return [n.strip() for n in os.environ.get("INTAKE_TEAM_ROSTER", "").split(",") if n.strip()]
# Dedicated room for reviewing CRM-drafted email-activity proposals (the CRM→Matrix push leg).
# Separate from the intake room so high-volume email proposals don't drown the conversational
# intake flow. Unset/empty disables the whole email-review poll loop (the bot just does intake).
def email_review_room():
return os.environ.get("MATRIX_EMAIL_REVIEW_ROOM", "").strip()
# Dedicated Q&A room for read-only natural-language queries (W2). In this room EVERY top-level
# message is treated as a question — no '?'/'@bot' trigger needed (the trigger only exists to
# disambiguate question-vs-note when Q&A shares the intake room; here that's unnecessary). The
# '?'/'@bot' trigger still works in the intake room too, as a cross-room convenience. Unset/empty
# just means no dedicated room (questions then go through the intake-room trigger). The bot must be
# a member of this room. Read-only — no approval gate, no redaction, no special power level needed.
def query_room():
return os.environ.get("MATRIX_QUERY_ROOM", "").strip()
+21
View File
@@ -0,0 +1,21 @@
"""Thin reuse of the in-repo local-Qwen client (backend/ingest/llm.py) via Spark Control.
We import the ingest client rather than re-implementing the HTTP call so the intake bot
speaks the exact same Spark contract (model, /v1/chat/completions, TLS verify, .env load).
The intake message is real LP substance, but it goes ONLY to the local Qwen on Ten31 infra
— never Claude — so no scrub boundary applies (same basis as the daily digest). Never call a
Spark directly; everything goes through SPARK_CONTROL_URL.
"""
import os
import sys
_INGEST = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ingest")
if _INGEST not in sys.path:
sys.path.insert(0, _INGEST)
import llm # noqa: E402 (backend/ingest/llm.py — chat / chat_json over Spark Control)
def parse_json(prompt, system=None, max_tokens=400):
"""Send to local Qwen (temp 0, thinking off) and parse the first JSON object, or None."""
return llm.chat_json(prompt, system=system, max_tokens=max_tokens)
+156
View File
@@ -0,0 +1,156 @@
"""Tests for the CRM client's payload builder (pure logic, no network)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import crm_client # noqa: E402
def test_new_investor_payload():
p = {"intent": "new_investor", "investor_name": "Acme Capital",
"contact_name": "Jane Doe", "contact_email": "jane@acme.com",
"contact_title": "GP", "note": "met at conf"}
out = crm_client.build_commit_payload(p)
assert out["investor_name"] == "Acme Capital"
assert out["create_investor_if_missing"] is True
assert "row_id" not in out
assert out["contact"] == {"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}
assert out["body"] == "met at conf"
assert out["source"] == "matrix_intake"
def test_existing_investor_uses_row_id_not_create():
p = {"intent": "meeting_note", "investor_name": "Acme Capital",
"contact_name": "Jane Doe", "contact_email": None, "note": "wants Q3 deck",
"_match_id": "rowAcme"}
out = crm_client.build_commit_payload(p)
assert out["row_id"] == "rowAcme"
assert "create_investor_if_missing" not in out
assert "investor_name" not in out # targeted by row id, never re-matched by name
assert out["body"] == "wants Q3 deck"
def test_contact_falls_back_to_investor_name_when_no_person():
p = {"intent": "new_investor", "investor_name": "Delta Fund",
"contact_name": None, "contact_email": None, "note": None}
out = crm_client.build_commit_payload(p)
assert out["contact"]["name"] == "Delta Fund"
assert out["body"] == ""
def test_no_email_sends_empty_string_not_none():
p = {"intent": "new_investor", "investor_name": "Gamma", "contact_name": "Bob",
"contact_email": None, "note": "x"}
out = crm_client.build_commit_payload(p)
assert out["contact"]["email"] == ""
def test_subject_blank_when_note_present_else_provenance_label():
# The CRM's grid note line uses subject-or-body, so a blank subject lets the note text show.
with_note = crm_client.build_commit_payload(
{"intent": "meeting_note", "investor_name": "Acme", "note": "sent the deck", "_match_id": "r1"})
assert with_note["subject"] == ""
assert with_note["body"] == "sent the deck"
# no note text → fall back to a provenance label so the grid line isn't empty
no_note = crm_client.build_commit_payload(
{"intent": "new_investor", "investor_name": "Beta", "contact_name": "X", "note": None})
assert no_note["subject"] == "Intake (Matrix)"
def _with_stub_authed(reply, capture=None):
"""Swap crm_client._authed for a canned (status, data); return a restorer."""
orig = crm_client._authed
def fake(method, path, body=None):
if capture is not None:
capture["path"] = path
return reply
crm_client._authed = fake
return orig
def test_match_parses_exact_match():
cap = {}
orig = _with_stub_authed((200, {"data": {
"match": {"id": "rowAcme", "investor_name": "Acme Capital", "matched_on": "name"},
"candidates": [],
}}), cap)
try:
res = crm_client.match({"investor_name": "Acme Capital", "contact_email": ""})
finally:
crm_client._authed = orig
assert res["match"] == {"id": "rowAcme", "name": "Acme Capital"}
assert res["candidates"] == []
assert "q=Acme" in cap["path"] # the query was forwarded
def test_match_returns_ranked_candidates_when_no_exact():
orig = _with_stub_authed((200, {"data": {"match": None, "candidates": [
{"id": "rowCharlie", "investor_name": "Charlie Brown", "score": 0.92, "matched_on": "name"},
{"id": "rowBeta", "investor_name": "Beta Capital LLC", "score": 0.86, "matched_on": "name"},
]}}))
try:
res = crm_client.match({"investor_name": "Charles Brown"})
finally:
crm_client._authed = orig
assert res["match"] is None
assert [c["id"] for c in res["candidates"]] == ["rowCharlie", "rowBeta"]
assert res["candidates"][0]["name"] == "Charlie Brown"
assert res["candidates"][0]["matched_on"] == "name"
def test_match_no_query_skips_network():
def boom(*a, **k):
raise AssertionError("should not hit the network when there's nothing to match on")
orig = crm_client._authed
crm_client._authed = boom
try:
res = crm_client.match({"investor_name": None, "contact_name": None, "contact_email": None})
finally:
crm_client._authed = orig
assert res == {"match": None, "candidates": []}
def test_nl_query_returns_endpoint_data():
cap = {}
orig = _with_stub_authed(
(200, {"data": {"intent": "top_investors_committed", "rows": [], "summary": "ok"}}), cap)
try:
res = crm_client.nl_query("top investors")
finally:
crm_client._authed = orig
assert res["intent"] == "top_investors_committed"
assert cap["path"] == "/api/query/nl"
def test_nl_query_passes_through_soft_503():
# Model-down still carries a structured body (the endpoint 503s with the error in `data`) —
# return it for the renderer to surface, don't raise.
orig = _with_stub_authed((503, {"data": {"error": "model_unavailable"}}))
try:
res = crm_client.nl_query("anything")
finally:
crm_client._authed = orig
assert res["error"] == "model_unavailable"
def test_nl_query_raises_on_auth_failure():
orig = _with_stub_authed((403, {"error": "Bot or admin required"}))
raised = False
try:
crm_client.nl_query("x")
except RuntimeError:
raised = True
finally:
crm_client._authed = orig
assert raised
if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print(f"ok {fn.__name__}")
print(f"\n{len(fns)} passed")
@@ -0,0 +1,78 @@
"""Offline tests for the email-proposal review logic (card render, framing, reply grammar, note
revision). The network/Matrix wiring lives in bot.py (live-smoke only); this covers pure functions."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import email_proposals # noqa: E402
ITEM = {
"id": "p1", "investor_name": "Acme Capital", "direction": "received",
"from_name": "Jane Doe", "from_email": "jane@acme.com",
"email_subject": "Re: Fund III", "email_date": "2026-06-02",
"snippet": "thanks for the deck — one question on terms",
"proposed_note": "✉ Jane Doe emailed the team: asked about terms",
}
def test_interpret_yes_no_else():
assert email_proposals.interpret("yes") == "approve"
assert email_proposals.interpret(" Y ") == "approve"
assert email_proposals.interpret("") == "approve"
assert email_proposals.interpret("no") == "reject"
assert email_proposals.interpret("skip") == "reject"
# anything that isn't a clear yes/no is treated as a revision instruction
assert email_proposals.interpret("say we discussed the Q3 raise") == "revise"
def test_frame_wraps_with_rules():
out = email_proposals.frame("hello")
lines = out.split("\n")
assert lines[0] == email_proposals.RULE and lines[-1] == email_proposals.RULE
assert "hello" in out
def test_render_card_has_context_note_and_actions():
card = email_proposals.render_card(ITEM)
assert "Acme Capital" in card
assert "Jane Doe" in card
assert "Re: Fund III" in card and "2026-06-02" in card
assert "thanks for the deck" in card
assert "Jane Doe emailed the team: asked about terms" in card # the clear, named note
assert "yes" in card.lower() and "no" in card.lower()
def test_render_card_is_framed_and_dropless_direction():
card = email_proposals.render_card(ITEM)
assert card.startswith(email_proposals.RULE) and card.rstrip().endswith(email_proposals.RULE)
# the bare Sent/Received label is gone — the note itself names who emailed whom
assert "(Received)" not in card and "(Sent)" not in card
def test_render_card_truncates_long_snippet():
card = email_proposals.render_card(dict(ITEM, snippet="x" * 1000))
assert "" in card and len(card) < 1000
def test_revise_note_applies_model_output():
out = email_proposals.revise_note(
"old note", "make it about the Q3 raise",
parse_fn=lambda prompt, system=None, max_tokens=400: {"note": "Discussed the Q3 raise."})
assert out == "Discussed the Q3 raise."
def test_revise_note_noop_or_empty_returns_none():
# model echoes the same note unchanged -> None so the caller re-prompts (not "Updated")
assert email_proposals.revise_note("same", "x", parse_fn=lambda *a, **k: {"note": "same"}) is None
# model returns nothing usable -> None
assert email_proposals.revise_note("n", "y", parse_fn=lambda *a, **k: {}) is None
assert email_proposals.revise_note("n", "y", parse_fn=lambda *a, **k: None) is None
if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print(f"ok {fn.__name__}")
print(f"\n{len(fns)} passed")
+37
View File
@@ -0,0 +1,37 @@
"""Tests for matrix_io content builders — pure dict shaping, no network."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import matrix_io # noqa: E402
def test_reply_content_is_plain_main_timeline_reply():
c = matrix_io.reply_content("hi", "$evt1")
rel = c["m.relates_to"]
assert rel["m.in_reply_to"]["event_id"] == "$evt1"
# a plain reply must NOT carry a thread relation, or it'd land in the thread
# instead of the main timeline (the whole point of the nudge).
assert "rel_type" not in rel
def test_reply_content_without_target_has_no_relation():
c = matrix_io.reply_content("hi", None)
assert "m.relates_to" not in c
assert c["body"] == "hi"
def test_thread_content_stays_threaded():
c = matrix_io.thread_content("hi", "$root1")
rel = c["m.relates_to"]
assert rel["rel_type"] == "m.thread"
assert rel["event_id"] == "$root1"
if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print(f"ok {fn.__name__}")
print(f"\n{len(fns)} passed")
+212
View File
@@ -0,0 +1,212 @@
"""Tests for the intake parse/normalize layer — Spark/Qwen stubbed (no network)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import parse # noqa: E402
def _stub(reply):
"""Return a parse_fn that ignores input and yields `reply` (simulating Qwen's JSON)."""
return lambda text, system=None, max_tokens=400: reply
def test_new_investor_basic():
p = parse.parse_message(
"New investor Acme Capital, contact Jane Doe jane@acme.com, met at the Austin conf",
parse_fn=_stub({"intent": "new_investor", "investor_name": "Acme Capital",
"contact_name": "Jane Doe", "contact_email": "jane@acme.com",
"contact_title": None, "note": "met at the Austin conf"}),
)
assert p["intent"] == "new_investor"
assert p["investor_name"] == "Acme Capital"
assert p["contact_email"] == "jane@acme.com"
def test_email_salvaged_from_source_when_model_misses():
p = parse.parse_message(
"add bob@example.org from Beta LP",
parse_fn=_stub({"intent": "new_investor", "investor_name": "Beta LP",
"contact_name": "Bob", "contact_email": None}),
)
assert p["contact_email"] == "bob@example.org"
def test_fabricated_email_dropped_when_not_in_source():
p = parse.parse_message(
"new prospect Gamma Partners, talked to their GP",
parse_fn=_stub({"intent": "new_investor", "investor_name": "Gamma Partners",
"contact_name": "their GP", "contact_email": "made-up@nowhere.test"}),
)
# the model invented an address that isn't in the source → must be dropped
assert p["contact_email"] is None
def test_email_extracted_without_surrounding_punctuation():
# "Name <addr>" is the most common contact format; parens / trailing period also occur.
# The salvage-from-source path must extract the bare address, never the brackets.
cases = [
("New investor: Larch Capital — Dana Reed <dana@larchcap.com>, met at conf", "dana@larchcap.com"),
("ping (sam@beta.io) re the deck", "sam@beta.io"),
("reach kim@acme.co.", "kim@acme.co"),
]
for src, expected in cases:
p = parse.parse_message(
src,
parse_fn=_stub({"intent": "new_investor", "investor_name": "X",
"contact_name": "Y", "contact_email": None}),
)
assert p["contact_email"] == expected, (src, p["contact_email"])
def test_meeting_note_intent_preserved():
p = parse.parse_message(
"Note for Acme Capital: wants the Q3 deck",
parse_fn=_stub({"intent": "meeting_note", "investor_name": "Acme Capital",
"note": "wants the Q3 deck"}),
)
assert p["intent"] == "meeting_note"
assert p["note"] == "wants the Q3 deck"
def test_unclear_when_no_entity():
p = parse.parse_message(
"hey what's up",
parse_fn=_stub({"intent": "new_investor", "investor_name": None, "contact_name": None}),
)
assert p["intent"] == "unclear"
def test_null_strings_normalized():
p = parse.parse_message(
"Delta Fund",
parse_fn=_stub({"intent": "new_investor", "investor_name": "Delta Fund",
"contact_name": "null", "contact_email": "N/A", "note": ""}),
)
assert p["contact_name"] is None
assert p["contact_email"] is None
assert p["note"] is None
def test_bad_intent_falls_back_to_unclear():
p = parse.parse_message(
"Epsilon Capital",
parse_fn=_stub({"intent": "garbage", "investor_name": "Epsilon Capital"}),
)
assert p["intent"] == "unclear"
def test_none_model_reply_is_unclear():
p = parse.parse_message("???", parse_fn=_stub(None))
assert p["intent"] == "unclear"
def test_parse_message_stashes_source_text():
p = parse.parse_message("Acme Capital, Jane jane@acme.com",
parse_fn=_stub({"intent": "new_investor", "investor_name": "Acme Capital",
"contact_name": "Jane", "contact_email": "jane@acme.com"}))
assert p["_source_text"] == "Acme Capital, Jane jane@acme.com"
def test_revise_applies_note_change_and_preserves_control_keys():
proposal = parse.parse_message(
"New investor Acme Capital, Jane Doe jane@acme.com",
parse_fn=_stub({"intent": "new_investor", "investor_name": "Acme Capital",
"contact_name": "Jane Doe", "contact_email": "jane@acme.com",
"contact_title": None, "note": None}))
revised = parse.revise(
proposal, "add that we met on June 14",
parse_fn=_stub({"investor_name": "Acme Capital", "contact_name": "Jane Doe",
"contact_email": "jane@acme.com", "contact_title": None,
"note": "met on June 14"}))
assert revised["note"] == "met on June 14"
assert revised["investor_name"] == "Acme Capital"
assert revised["intent"] == "new_investor" # control key preserved
assert revised["_source_text"] == proposal["_source_text"] # preserved for email integrity
def test_revise_email_taken_only_from_instruction():
proposal = {"intent": "new_investor", "investor_name": "Acme", "contact_name": "Jane",
"contact_email": "jane@acme.com", "contact_title": None, "note": None,
"_source_text": "Acme, Jane jane@acme.com"}
# instruction literally carries the new address → accepted
r1 = parse.revise(proposal, "her email is jane@newfirm.com",
parse_fn=_stub({"contact_email": "jane@newfirm.com"}))
assert r1["contact_email"] == "jane@newfirm.com"
# model tries to change the email but the instruction has no address → keep the existing one
r2 = parse.revise(proposal, "set her title to GP",
parse_fn=_stub({"contact_email": "totally@madeup.test", "contact_title": "GP"}))
assert r2["contact_email"] == "jane@acme.com" # model's email ignored (not in instruction)
assert r2["contact_title"] == "GP"
def test_revise_preserves_match_id():
proposal = {"intent": "meeting_note", "investor_name": "Acme", "contact_name": None,
"contact_email": None, "contact_title": None, "note": "old",
"_match_id": "rowAcme", "_stage": "approval", "_source_text": "note for Acme: old"}
revised = parse.revise(proposal, "change the note to: sent the deck",
parse_fn=_stub({"note": "sent the deck"}))
assert revised["note"] == "sent the deck"
assert revised["_match_id"] == "rowAcme"
assert revised["intent"] == "meeting_note"
def test_build_system_appends_roster_frame_only_when_roster_given():
base = parse.build_system()
assert base.strip().endswith("Output JSON only.")
assert "doing the outreach" not in base # no roster → no outreach frame
framed = parse.build_system(["Grant", "Jonathan", "Marty"])
assert "Grant" in framed and "Jonathan" in framed and "Marty" in framed
assert "doing the outreach" in framed # the outreach frame is present
assert framed.strip().endswith("Output JSON only.") # JSON-only stays last for recency
def test_parse_message_injects_roster_into_system_prompt():
# Capture the system prompt the model is handed, and confirm the teammate ("jonathan")
# is framed as outreach while the prospect ("wyoming") is what gets extracted.
seen = {}
def cap(text, system=None, max_tokens=400):
seen["system"] = system
return {"intent": "meeting_note", "investor_name": "Wyoming", "contact_name": None,
"note": "jonathan chatting with them"}
p = parse.parse_message("jonathan is chatting with wyoming", parse_fn=cap,
roster=["Grant", "Jonathan", "Marty"])
assert "Jonathan" in seen["system"]
assert "doing the outreach" in seen["system"]
assert p["investor_name"] == "Wyoming"
def test_revise_injects_roster_into_system_prompt():
proposal = {"intent": "meeting_note", "investor_name": "Wyoming", "contact_name": None,
"contact_email": None, "contact_title": None, "note": "x",
"_source_text": "jonathan is chatting with wyoming"}
seen = {}
def cap(prompt, system=None, max_tokens=400):
seen["system"] = system
return {"note": "sent the deck"}
parse.revise(proposal, "note: sent the deck", parse_fn=cap, roster=["Grant", "Jonathan"])
assert "Jonathan" in seen["system"]
assert "doing the outreach" in seen["system"]
def test_revise_cannot_empty_the_proposal():
proposal = {"intent": "new_investor", "investor_name": "Acme", "contact_name": "Jane",
"contact_email": None, "contact_title": None, "note": "x", "_source_text": "Acme Jane"}
revised = parse.revise(proposal, "clear it",
parse_fn=_stub({"investor_name": None, "contact_name": None,
"contact_title": None, "note": None}))
assert revised["investor_name"] == "Acme" and revised["contact_name"] == "Jane"
if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print(f"ok {fn.__name__}")
print(f"\n{len(fns)} passed")
+187
View File
@@ -0,0 +1,187 @@
"""Tests for the proposal store + approval state machine (pure logic, no network)."""
import copy
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import proposals # noqa: E402
SAMPLE = {"intent": "new_investor", "investor_name": "Acme Capital",
"contact_name": "Jane Doe", "contact_email": "jane@acme.com",
"contact_title": None, "note": "met at conf"}
def test_store_put_get_pop():
s = proposals.ProposalStore()
assert not s.has("$root")
s.put("$root", SAMPLE)
assert s.has("$root")
assert s.get("$root")["investor_name"] == "Acme Capital"
assert s.pop("$root")["investor_name"] == "Acme Capital"
assert not s.has("$root")
assert s.pop("$missing") is None
def test_store_any_pending():
s = proposals.ProposalStore()
assert not s.any_pending()
s.put("$r", SAMPLE)
assert s.any_pending()
s.pop("$r")
assert not s.any_pending()
def test_interpret_yes_variants():
for t in ("yes", "Y", "approve", " ok ", "👍"):
assert proposals.interpret_reply(t)[0] == "approve", t
def test_interpret_no_variants():
for t in ("no", "N", "cancel", "discard", ""):
assert proposals.interpret_reply(t)[0] == "reject", t
def test_interpret_edit_equals():
action, payload = proposals.interpret_reply("edit email=new@acme.com")
assert action == "edit"
assert payload == ("contact_email", "new@acme.com")
def test_interpret_edit_colon_and_alias():
action, payload = proposals.interpret_reply("firm: Acme Capital LLC")
assert action == "edit"
assert payload == ("investor_name", "Acme Capital LLC")
def test_interpret_unknown():
assert proposals.interpret_reply("maybe later")[0] == "unknown"
def test_interpret_edit_colon_value_contains_equals():
# the '=' inside the value must not break parsing — split on ':' first, keep the rest
action, payload = proposals.interpret_reply("note: see deck=v2")
assert action == "edit"
assert payload == ("note", "see deck=v2")
def test_claim_once_pop_guards_double_approve():
# the double-approve guard relies on pop() yielding the proposal exactly once;
# a second claim returns None so a racing second 'yes' is a no-op
s = proposals.ProposalStore()
s.put("$r", SAMPLE)
assert s.pop("$r") is not None
assert s.pop("$r") is None
def test_edit_with_unknown_field_is_not_an_edit():
# an unknown field name must not silently become an edit
assert proposals.interpret_reply("edit zipcode=90210")[0] == "unknown"
def test_apply_edit_is_nondestructive():
updated = proposals.apply_edit(SAMPLE, "contact_email", "x@y.com")
assert updated["contact_email"] == "x@y.com"
assert SAMPLE["contact_email"] == "jane@acme.com" # original untouched
def test_render_includes_fields_and_instructions():
text = proposals.render(SAMPLE)
assert "Acme Capital" in text
assert "jane@acme.com" in text
assert "yes" in text.lower() and "no" in text.lower()
def test_render_meeting_note_variant():
note = dict(SAMPLE, intent="meeting_note")
assert "meeting note" in proposals.render(note).lower()
def test_summary_line_new_vs_note():
new_line = proposals.summary_line(SAMPLE)
assert "Acme Capital" in new_line and "new investor" in new_line.lower()
note_line = proposals.summary_line(dict(SAMPLE, intent="meeting_note"))
assert "Acme Capital" in note_line and "meeting note" in note_line.lower()
# the nudge must point the user to the thread, where the actual action lives
assert "thread" in new_line.lower()
# --- fuzzy-match disambiguation + conversational-revision helpers ---
DISAMBIG = {"intent": "new_investor", "investor_name": "Charles Brown",
"contact_name": "Charles Brown", "contact_email": None, "contact_title": None,
"note": "met at conf", "_stage": "disambiguate",
"_candidates": [{"id": "rowCharlie", "name": "Charlie Brown", "score": 0.92, "matched_on": "name"},
{"id": "rowBeta", "name": "Beta Capital LLC", "score": 0.7, "matched_on": "name"}]}
def test_interpret_disambiguation_pick_number():
assert proposals.interpret_disambiguation("1", 2) == ("pick", 0)
assert proposals.interpret_disambiguation(" 2 ", 2) == ("pick", 1)
assert proposals.interpret_disambiguation("#1", 2) == ("pick", 0)
def test_interpret_disambiguation_out_of_range_is_unknown():
assert proposals.interpret_disambiguation("3", 2)[0] == "unknown"
assert proposals.interpret_disambiguation("0", 2)[0] == "unknown"
def test_interpret_disambiguation_new_and_no():
assert proposals.interpret_disambiguation("new", 2)[0] == "new"
assert proposals.interpret_disambiguation("none of these", 2)[0] == "new"
assert proposals.interpret_disambiguation("no", 2)[0] == "reject"
def test_interpret_disambiguation_freeform_is_unknown():
# a free-form reply in the shortlist stage isn't guessed at — re-prompt instead
assert proposals.interpret_disambiguation("the first one", 2)[0] == "unknown"
def test_attach_to_candidate_promotes_to_meeting_note():
out = proposals.attach_to_candidate(DISAMBIG, DISAMBIG["_candidates"][0])
assert out["_match_id"] == "rowCharlie"
assert out["intent"] == "meeting_note"
assert out["_stage"] == "approval"
assert out["investor_name"] == "Charlie Brown" # canonical existing name shown
assert "_candidates" not in out
assert "_candidates" in DISAMBIG # original untouched
def test_promote_to_new_clears_shortlist_and_match():
out = proposals.promote_to_new(dict(DISAMBIG, _match_id="rowX"))
assert out["_stage"] == "approval"
assert "_candidates" not in out
assert "_match_id" not in out
def test_disambiguation_pick_then_yes_reaches_approval():
# Closes the seam between the two state machines: a shortlist pick promotes the proposal to
# approval stage carrying the chosen investor's row id, and a following 'yes' classifies as
# approve (the normal commit path) — so pick -> yes lands the note on the existing investor.
picked = proposals.attach_to_candidate(copy.deepcopy(DISAMBIG), DISAMBIG["_candidates"][0])
assert picked["_stage"] == "approval"
assert picked["_match_id"] == "rowCharlie"
assert picked["intent"] == "meeting_note"
assert proposals.interpret_reply("yes") == ("approve", None)
def test_render_disambiguation_lists_numbered_candidates():
text = proposals.render_disambiguation(DISAMBIG)
assert "Charlie Brown" in text and "Beta Capital LLC" in text
assert "1." in text and "2." in text
assert "new" in text.lower() and "no" in text.lower()
def test_same_fields_ignores_control_keys():
a = dict(SAMPLE)
assert proposals.same_fields(a, dict(a))
assert not proposals.same_fields(a, dict(a, note="different"))
assert proposals.same_fields(a, dict(a, _match_id="r1", _stage="approval"))
if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print(f"ok {fn.__name__}")
print(f"\n{len(fns)} passed")
+112
View File
@@ -0,0 +1,112 @@
"""Tests for the NL-query Matrix surface: trigger detection + answer rendering (pure, no network)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import query # noqa: E402
# ── parse_trigger ───────────────────────────────────────────────────────────────────────
def test_trigger_question_mark():
assert query.parse_trigger("?who are our top investors") == "who are our top investors"
assert query.parse_trigger(" ? spaced out ") == "spaced out"
def test_trigger_at_bot():
assert query.parse_trigger("@bot top 10 investors") == "top 10 investors"
assert query.parse_trigger("@bot: top 10 investors") == "top 10 investors" # pill-style colon
assert query.parse_trigger("@BOT spaced") == "spaced" # case-insensitive
def test_trigger_slash_forms():
assert query.parse_trigger("/ask when did we last email Acme?") == "when did we last email Acme?"
assert query.parse_trigger("/query top investors") == "top investors"
assert query.parse_trigger("/q top investors") == "top investors"
def test_trigger_bare_returns_empty_string():
# A bare trigger is matched (so we show help) but carries no question.
assert query.parse_trigger("@bot") == ""
assert query.parse_trigger("?") == ""
def test_non_trigger_routes_to_intake():
assert query.parse_trigger("New investor: Acme — Jane <jane@acme.com>") is None
# 'ask' as a note verb must NOT trigger (would collide with real intake notes).
assert query.parse_trigger("Ask Jane to send the Q3 deck") is None
assert query.parse_trigger("/asking for a friend") is None # needs a separator after /ask
assert query.parse_trigger("") is None
assert query.parse_trigger(" ") is None
# ── render_answer ───────────────────────────────────────────────────────────────────────
def test_render_scalar_rows():
out = query.render_answer({
"intent": "top_investors_committed", "slots": {"limit": 2},
"summary": "Top 2 investor(s) by committed capital.",
"columns": ["investor_name", "total_invested", "lead"],
"rows": [{"investor_name": "Acme Capital", "total_invested": 5000000, "lead": "Grant"},
{"investor_name": "Beta Fund", "total_invested": 2500000, "lead": "Jonathan"}],
"truncated": False})
assert "Top 2 investor(s)" in out
assert "**Acme Capital**" in out
assert "$5,000,000" in out # money formatting
assert "read as: top_investors_committed" in out # interpretation footer
def test_render_nested_contacts_and_commitments():
out = query.render_answer({
"intent": "investor_lookup", "slots": {"name": "Acme"},
"summary": '1 investor(s) matching "Acme".',
"columns": ["investor_name", "lead", "total_invested", "graveyard", "contacts", "commitments"],
"rows": [{"investor_name": "Acme Capital", "lead": "Grant", "total_invested": 5000000,
"graveyard": 0,
"contacts": [{"full_name": "Jane Doe", "email": "jane@acme.com", "title": "GP",
"city": "Austin", "state": "TX", "country": ""}],
"commitments": [{"fund_name": "Fund I", "amount": 5000000}]}],
"truncated": False})
assert "Jane Doe <jane@acme.com>" in out
assert "Fund I: $5,000,000" in out
assert "graveyard" not in out # 0-valued flag column suppressed
def test_render_flag_when_set():
out = query.render_answer({
"intent": "investors_follow_up", "slots": {},
"summary": "1 investor(s) with an open follow-up reminder.",
"columns": ["investor_name", "title", "due_date", "status", "overdue"],
"rows": [{"investor_name": "Acme", "title": "Send deck", "due_date": "2026-01-01",
"status": "open", "overdue": 1}]})
assert "⚠️ overdue" in out
assert "2026-01-01" in out # date truncated to YYYY-MM-DD
def test_render_no_rows():
out = query.render_answer({"intent": "investors_by_city", "slots": {"city": "Nowhere"},
"summary": '0 investor contact(s) in "Nowhere".',
"columns": [], "rows": []})
assert "no matching" in out.lower()
def test_render_overflow_note():
rows = [{"investor_name": f"Inv {i}", "total_invested": i}
for i in range(query.MAX_DISPLAY_ROWS + 5)]
out = query.render_answer({"intent": "top_investors_committed", "slots": {}, "summary": "many",
"columns": ["investor_name", "total_invested"], "rows": rows})
assert "+5 more not shown" in out
def test_render_errors():
assert "couldn't map" in query.render_answer({"error": "no_match", "question": "huh"}).lower()
assert "unreachable" in query.render_answer({"error": "model_unavailable"}).lower()
assert "failed" in query.render_answer({"error": "query_failed", "detail": "boom"}).lower()
assert "bad_slot" in query.render_answer({"error": "bad_slot", "detail": "x"})
if __name__ == "__main__":
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print(f"ok {fn.__name__}")
print(f"\n{len(fns)} passed")
@@ -0,0 +1,7 @@
-- Reversal of 0005_grid_pipeline_link.sql (manual; .down files are never auto-applied).
--
-- SQLite < 3.35 cannot DROP COLUMN. The added column is nullable and ignored by any code
-- path predating it, so leaving it in place is harmless. The index drops freely. On
-- SQLite >= 3.35 the column itself may also be dropped.
DROP INDEX IF EXISTS idx_opportunities_fr_investor;
-- ALTER TABLE opportunities DROP COLUMN fundraising_investor_id; -- SQLite >= 3.35 only
@@ -0,0 +1,22 @@
-- Grid → Pipeline adoption — a durable link from a fundraising-grid investor to its
-- Pipeline opportunity row.
--
-- ADDITIVE + REVERSIBLE (CLAUDE.md guardrail #3): adds one nullable column + index.
-- Until now the grid's "Create Opportunity" button fired a one-shot POST with no
-- back-reference, so a grid investor could spawn unlimited duplicate opportunities and
-- an opp never knew which grid row it belonged to. opportunities.fundraising_investor_id
-- records the link (set by the new POST /api/fundraising/pipeline/link endpoint), making
-- the relationship dedup-able and reconcilable. "Is this investor in the pipeline?" and
-- "what stage?" are then DERIVED from a live join on this column — deliberately not a
-- denormalized mirror flag on fundraising_investors, which would only reintroduce the
-- two-model drift this CRM exists to fight.
--
-- fundraising_investor_id is a LOGICAL foreign key to fundraising_investors(id). It is
-- intentionally NOT a declared SQLite FOREIGN KEY: opportunities are soft-deleted (never
-- hard-deleted) and fundraising_investors rows are rebuilt on every grid save, so there
-- is nothing to cascade; SQLite's ALTER TABLE ADD COLUMN cannot add an enforced FK
-- cleanly anyway. Nullable so every existing opportunity stays valid — a manually-created,
-- non-grid opportunity simply has NULL here.
ALTER TABLE opportunities ADD COLUMN fundraising_investor_id TEXT;
CREATE INDEX IF NOT EXISTS idx_opportunities_fr_investor ON opportunities(fundraising_investor_id);
@@ -0,0 +1,8 @@
-- Manual rollback for 0006_reminders.sql (never auto-applied).
-- Drops the whole reminders feature table. Per the never-hard-delete guardrail this
-- discards reminder history, so only run it to reverse a bad migration on a dev/copy DB.
DROP INDEX IF EXISTS idx_reminders_assignee;
DROP INDEX IF EXISTS idx_reminders_due;
DROP INDEX IF EXISTS idx_reminders_status;
DROP INDEX IF EXISTS idx_reminders_investor;
DROP TABLE IF EXISTS reminders;
+45
View File
@@ -0,0 +1,45 @@
-- Reminders & follow-ups — a real tickler/task model tied to the fundraising grid.
--
-- ADDITIVE + REVERSIBLE (CLAUDE.md guardrail #3): one new table + indexes; nothing
-- existing is touched. Until now the only follow-up surfaces were the grid's binary
-- `follow_up` checkbox (no date, owner, or status) and communications.next_action_date
-- (tied to a single logged comm). This gives investors first-class reminders with a due
-- date, status lifecycle, assignee, and provenance — the foundation for "who needs a
-- follow-up?" queries, the daily digest's due/overdue section, and (later) bot-proposed
-- reminders behind the Matrix approval gate.
--
-- investor_id is a LOGICAL foreign key to fundraising_investors(id) — deliberately NOT a
-- declared SQLite FOREIGN KEY, matching opportunities.fundraising_investor_id (migration
-- 0005). fundraising_investors rows are upserted by source_row_id on every grid save with
-- a STABLE id (so the link survives saves), but a row dropped from the grid is DELETEd —
-- there is nothing to cascade, and reconcile_grid_reminders() cancels the orphans on the
-- next save (the pipeline reconciler's twin). investor_name is denormalized so a reminder
-- stays readable in history even after its grid row is gone. investor_id is nullable: a
-- reminder can be a standalone team task not tied to any investor.
--
-- contact_id is an optional logical FK to contacts(id) (the specific person). assignee_id
-- is a logical ref to users(id) (NULL = team-wide). created_by holds a users.id OR a
-- non-user sentinel ('bot'/'automation'), so it is plain TEXT with no FK.
CREATE TABLE IF NOT EXISTS reminders (
id TEXT PRIMARY KEY,
investor_id TEXT, -- logical FK -> fundraising_investors.id (NULL = standalone task)
investor_name TEXT, -- denormalized; survives grid-row deletion
contact_id TEXT, -- optional logical FK -> contacts.id
title TEXT NOT NULL,
details TEXT,
due_date TEXT, -- ISO date 'YYYY-MM-DD' (or datetime)
status TEXT NOT NULL DEFAULT 'open', -- open | done | snoozed | cancelled
snoozed_until TEXT,
assignee_id TEXT, -- logical ref -> users.id; NULL = team-wide
created_by TEXT, -- users.id, or 'bot' / 'automation'
source TEXT NOT NULL DEFAULT 'human', -- human | bot | automation
completed_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_reminders_investor ON reminders(investor_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(due_date) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_reminders_assignee ON reminders(assignee_id) WHERE deleted_at IS NULL;
+9
View File
@@ -0,0 +1,9 @@
"""nl_query — the safe, read-only natural-language query surface (W2).
The LLM's job (added later) is only to map a question to a {intent, slots} pair; everything
that touches the database lives here behind a strict validator and a fixed, hand-written,
parameterized query catalog. See runner.py (the trust boundary) and intents.py (the catalog).
"""
from .runner import run_query, validate, catalog # noqa: F401
from .intents import INTENTS # noqa: F401
from .translate import translate, answer, build_system # noqa: F401
+440
View File
@@ -0,0 +1,440 @@
"""NL-query intents — the curated, hand-written query catalog (W2, the safe core).
Each intent is a FIXED, reviewed, parameterized SQL query with a small set of typed
"slots" (the blanks a question fills in: a number of days, a name, a limit). There is NO
generic SQL/AST compiler and NO dynamically-built identifiers: every table and column name
is hardcoded in the query text, and every value the caller (or an LLM) supplies reaches
SQLite only as a bound `?` parameter. That is the whole trust model — a malformed or
hostile request can change a bound value, never the query structure. Adding a capability
means adding a reviewed entry here, not widening a language.
Soft-delete discipline (CLAUDE.md standing rule), per table:
- reminders / opportunities / communications carry `deleted_at` -> filter `deleted_at IS NULL`.
- emails have NO `deleted_at`; "live" means a non-tombstoned per-mailbox sighting exists
(`email_account_messages.deleted_at IS NULL`) — mirror the digest / query_email_activity.
- fundraising_investors/_contacts/_funds/_commitments are a HARD-REBUILT projection of the
grid blob with NO `deleted_at` column; the live/retired axis there is the `graveyard` flag.
Do NOT add `deleted_at IS NULL` to those tables — the column does not exist and the clause
would raise. Exclude `graveyard = 1` where the question means "live" investors.
Each run_* returns {columns, rows, summary, truncated}. `summary` is a DETERMINISTIC local
one-liner (never an LLM narrative) — results never leave the box to be summarized.
"""
import sqlite3
from datetime import datetime, timedelta
# Generous ceiling — the Matrix review room is two admins and the web app is internal, so
# dumping the full book is acceptable (per Grant); this only guards against an unbounded
# scan flooding a response. A list intent past this is reported truncated, never silently cut.
MAX_ROWS = 500
# Live, non-terminal pipeline stages in funnel order (mirrors server.PIPELINE_STAGES; 'lost'
# is the terminal drop). Kept here so the pipeline intents have a stable rank without importing
# the server module (helpers take a conn; they never import server — house convention).
_STAGE_ORDER = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded']
_STAGE_RANK_SQL = (
"CASE stage WHEN 'lead' THEN 1 WHEN 'outreach' THEN 2 WHEN 'meeting' THEN 3 "
"WHEN 'due_diligence' THEN 4 WHEN 'committed' THEN 5 WHEN 'funded' THEN 6 ELSE 0 END")
# ── helpers ────────────────────────────────────────────────────────────────────────────
def _rows(cur):
"""Materialize a cursor as a list of plain dicts, independent of the connection's
row_factory (works whether rows come back as tuples or sqlite3.Row)."""
cols = [c[0] for c in cur.description]
return [dict(zip(cols, r)) for r in cur.fetchall()]
def like_contains(value):
"""Build a safe LIKE pattern for a free-text contains match. Escapes the LIKE
wildcards so a user/LLM value of '%' or '_' is treated literally — paired with
`LIKE ? ESCAPE '\\'` in the SQL, this stops '%' from matching the entire table."""
v = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{v}%"
def _last_activity_by_investor(conn):
"""{fundraising_investors.id: latest activity ISO ts} across logged communications and
captured grid-linked emails — the per-investor recency signal behind the "gone quiet"
and "last contact" intents.
NB: this MIRRORS server.last_activity_by_investor() and its soft-delete joins (comms via
cm.deleted_at IS NULL; email via a live email_account_messages sighting). It is duplicated
rather than imported only to keep this module free of a server import (the main module runs
as __main__, so `import server` would re-execute it). Keep the two in sync; the soft-delete
test guards this copy. If a third caller appears, extract both to a shared module."""
out = {}
def _bump(inv_id, ts):
if inv_id and ts and (out.get(inv_id) is None or str(ts) > str(out[inv_id])):
out[inv_id] = ts
# Each leg is guarded: the comms/email tables can be absent on a minimal DB. This is a
# narrow, intentional tolerance for optional tables — NOT the broad error-swallowing the
# runner forbids (a failure in an intent's main query surfaces as query_failed).
try:
for r in conn.execute(
"SELECT fc.investor_id AS inv, MAX(cm.communication_date) AS last_ts "
"FROM communications cm JOIN fundraising_contacts fc ON fc.contact_id = cm.contact_id "
"WHERE cm.deleted_at IS NULL AND fc.contact_id IS NOT NULL GROUP BY fc.investor_id"):
_bump(r["inv"], r["last_ts"])
except sqlite3.OperationalError:
pass
try:
for r in conn.execute(
"SELECT eil.fundraising_investor_id AS inv, MAX(e.sent_at) AS last_ts "
"FROM email_investor_links eil JOIN emails e ON e.id = eil.email_id "
"WHERE eil.fundraising_investor_id IS NOT NULL AND EXISTS "
"(SELECT 1 FROM email_account_messages eam WHERE eam.email_id = e.id "
"AND eam.deleted_at IS NULL) GROUP BY eil.fundraising_investor_id"):
_bump(r["inv"], r["last_ts"])
except sqlite3.OperationalError:
pass
return out
def _today():
return datetime.utcnow().date()
def _days_since(ts):
"""Whole days between an ISO date/datetime string and today (UTC). None if unparseable."""
if not ts:
return None
try:
d = datetime.fromisoformat(str(ts)[:10].replace("Z", "")).date()
except ValueError:
return None
return (_today() - d).days
def _own_addresses(conn):
try:
return {(r[0] or "").lower().strip()
for r in conn.execute("SELECT email_address FROM email_accounts")} - {""}
except sqlite3.OperationalError:
return set()
def _truncate(rows):
"""Apply the global ceiling, returning (rows, truncated)."""
if len(rows) > MAX_ROWS:
return rows[:MAX_ROWS], True
return rows, False
# ── investor intents ─────────────────────────────────────────────────────────────────────
def run_investors_cold(conn, slots):
"""Live investors not contacted in `days` days — never-contacted first, then oldest."""
days = slots["days"]
cutoff = (_today() - timedelta(days=days)).isoformat()
last = _last_activity_by_investor(conn)
invs = _rows(conn.execute(
"SELECT id, investor_name, lead, total_invested FROM fundraising_investors "
"WHERE graveyard = 0 ORDER BY investor_name"))
cold = []
for inv in invs:
ts = last.get(inv["id"])
if ts is None or str(ts)[:10] < cutoff:
cold.append({"investor_name": inv["investor_name"], "lead": inv["lead"],
"total_invested": inv["total_invested"],
"last_activity_at": ts, "days_since": _days_since(ts)})
# never-contacted (days_since None) first, then most-stale first
cold.sort(key=lambda r: (r["days_since"] is not None, -(r["days_since"] or 0)))
rows, trunc = _truncate(cold)
return {"columns": ["investor_name", "lead", "total_invested", "last_activity_at", "days_since"],
"rows": rows, "truncated": trunc,
"summary": f"{len(cold)} live investor(s) not contacted in {days}+ days."}
def run_investor_lookup(conn, slots):
"""One investor's profile: contacts (name/email/title/city), committed total, per-fund
commitments, lead. Name matched as a contains (an LLM/user may pass a partial)."""
pat = like_contains(slots["name"])
invs = _rows(conn.execute(
"SELECT id, investor_name, lead, lead_source, total_invested, follow_up, graveyard "
"FROM fundraising_investors WHERE investor_name LIKE ? ESCAPE '\\' "
"ORDER BY graveyard, investor_name LIMIT 25", (pat,)))
for inv in invs:
inv["contacts"] = _rows(conn.execute(
"SELECT full_name, email, title, city, state, country FROM fundraising_contacts "
"WHERE investor_id = ? ORDER BY sort_order, full_name", (inv["id"],)))
inv["commitments"] = _rows(conn.execute(
"SELECT f.fund_name, c.amount FROM fundraising_commitments c "
"JOIN fundraising_funds f ON f.id = c.fund_id WHERE c.investor_id = ? AND c.amount <> 0 "
"ORDER BY f.display_order", (inv["id"],)))
inv.pop("id", None)
return {"columns": ["investor_name", "lead", "lead_source", "total_invested",
"follow_up", "graveyard", "contacts", "commitments"],
"rows": invs, "truncated": False,
"summary": f"{len(invs)} investor(s) matching \"{slots['name']}\"."}
def run_investors_by_city(conn, slots):
"""Investors with a contact located in `city` (contains match on the contact's city)."""
pat = like_contains(slots["city"])
rows = _rows(conn.execute(
"SELECT i.investor_name, c.full_name AS contact, c.city, c.state, c.country, i.lead "
"FROM fundraising_contacts c JOIN fundraising_investors i ON i.id = c.investor_id "
"WHERE i.graveyard = 0 AND c.city LIKE ? ESCAPE '\\' "
"ORDER BY i.investor_name, c.full_name LIMIT ?", (pat, MAX_ROWS + 1)))
rows, trunc = _truncate(rows)
return {"columns": ["investor_name", "contact", "city", "state", "country", "lead"],
"rows": rows, "truncated": trunc,
"summary": f"{len(rows)} investor contact(s) in \"{slots['city']}\"."}
def run_investors_by_lead(conn, slots):
"""Live investors owned by a given lead/team member (contains match on `lead`)."""
pat = like_contains(slots["lead"])
rows = _rows(conn.execute(
"SELECT investor_name, lead, total_invested, follow_up FROM fundraising_investors "
"WHERE graveyard = 0 AND lead LIKE ? ESCAPE '\\' "
"ORDER BY total_invested DESC, investor_name LIMIT ?", (pat, MAX_ROWS + 1)))
rows, trunc = _truncate(rows)
return {"columns": ["investor_name", "lead", "total_invested", "follow_up"],
"rows": rows, "truncated": trunc,
"summary": f"{len(rows)} live investor(s) led by \"{slots['lead']}\"."}
def run_top_investors_committed(conn, slots):
"""Top `limit` live investors by total committed capital across all funds."""
n = slots["limit"]
rows = _rows(conn.execute(
"SELECT investor_name, total_invested, lead FROM fundraising_investors "
"WHERE graveyard = 0 AND total_invested > 0 "
"ORDER BY total_invested DESC, investor_name LIMIT ?", (n,)))
return {"columns": ["investor_name", "total_invested", "lead"], "rows": rows,
"truncated": False, "summary": f"Top {len(rows)} investor(s) by committed capital."}
def run_investors_follow_up(conn, slots):
"""Investors we owe a follow-up to: those with an OPEN reminder, overdue first. Uses the
W1 reminders table (the richer follow-up layer) joined to the current grid name."""
today = _today().isoformat()
rows = _rows(conn.execute(
"SELECT COALESCE(i.investor_name, r.investor_name) AS investor_name, r.title, "
"r.due_date, r.status, r.assignee_id, "
"CASE WHEN r.due_date IS NOT NULL AND substr(r.due_date,1,10) < ? THEN 1 ELSE 0 END AS overdue "
"FROM reminders r LEFT JOIN fundraising_investors i ON i.id = r.investor_id "
"WHERE r.deleted_at IS NULL AND r.status = 'open' AND r.investor_id IS NOT NULL "
"ORDER BY (r.due_date IS NULL), r.due_date ASC LIMIT ?", (today, MAX_ROWS + 1)))
rows, trunc = _truncate(rows)
return {"columns": ["investor_name", "title", "due_date", "status", "overdue"],
"rows": rows, "truncated": trunc,
"summary": f"{len(rows)} investor(s) with an open follow-up reminder."}
# ── pipeline intents ──────────────────────────────────────────────────────────────────────
def run_pipeline_top(conn, slots):
"""Top `limit` live pipeline opportunities by stage (furthest along first), with the
investor, owner, and most-recent activity."""
n = slots["limit"]
last = _last_activity_by_investor(conn)
rows = _rows(conn.execute(
"SELECT o.fundraising_investor_id AS inv_id, "
"COALESCE(i.investor_name, o.name) AS investor_name, o.stage, o.expected_amount, "
"o.probability, u.full_name AS owner FROM opportunities o "
"LEFT JOIN fundraising_investors i ON i.id = o.fundraising_investor_id "
"LEFT JOIN users u ON u.id = o.owner_id "
"WHERE o.deleted_at IS NULL AND o.stage != 'lost' "
f"ORDER BY {_STAGE_RANK_SQL} DESC, o.expected_amount DESC LIMIT ?", (n,)))
for r in rows:
r["last_activity_at"] = last.get(r.pop("inv_id"))
return {"columns": ["investor_name", "stage", "expected_amount", "probability", "owner",
"last_activity_at"],
"rows": rows, "truncated": False,
"summary": f"Top {len(rows)} live pipeline opportunit(ies) by stage."}
def run_pipeline_totals(conn, slots):
"""Total pipeline dollars and the split across each stage (excludes lost)."""
rows = _rows(conn.execute(
"SELECT stage, COUNT(*) AS count, COALESCE(SUM(expected_amount),0) AS expected_total, "
"COALESCE(SUM(commitment_amount),0) AS committed_total FROM opportunities "
f"WHERE deleted_at IS NULL AND stage != 'lost' GROUP BY stage ORDER BY {_STAGE_RANK_SQL}"))
total = sum(r["expected_total"] for r in rows)
count = sum(r["count"] for r in rows)
return {"columns": ["stage", "count", "expected_total", "committed_total"],
"rows": rows, "truncated": False,
"summary": f"${total:,.0f} expected across {count} live opportunit(ies) in "
f"{len(rows)} stage(s)."}
# ── email / communication intents ─────────────────────────────────────────────────────────
def run_recent_emails(conn, slots):
"""The most recent `limit` matched investor emails, optionally one direction.
Matched-only + soft-delete-correct (a live email_account_messages sighting), mirroring
the Communications panel's query_email_activity."""
n, direction = slots["limit"], slots["direction"]
where = ["EXISTS (SELECT 1 FROM email_account_messages eam WHERE eam.email_id = e.id "
"AND eam.deleted_at IS NULL)",
"EXISTS (SELECT 1 FROM email_investor_links l WHERE l.email_id = e.id)"]
params = []
own = _own_addresses(conn)
if direction in ("inbound", "outbound") and own:
op = "IN" if direction == "outbound" else "NOT IN"
where.append(f"LOWER(e.from_email) {op} ({','.join('?' for _ in own)})")
params.extend(sorted(own))
sql = ("SELECT e.subject, e.from_name, e.from_email, e.sent_at, "
"(SELECT fi.investor_name FROM email_investor_links l "
" JOIN fundraising_investors fi ON fi.id = l.fundraising_investor_id "
" WHERE l.email_id = e.id AND l.fundraising_investor_id IS NOT NULL LIMIT 1) AS investor "
"FROM emails e WHERE " + " AND ".join(where) + " ORDER BY e.sent_at DESC LIMIT ?")
rows = _rows(conn.execute(sql, params + [n]))
label = {"inbound": "received", "outbound": "sent"}.get(direction, "")
return {"columns": ["sent_at", "subject", "from_name", "from_email", "investor"],
"rows": rows, "truncated": False,
"summary": f"{len(rows)} most-recent {label + ' ' if label else ''}investor email(s)."}
def run_investor_last_contact(conn, slots):
"""When we last had any activity with investor X (matched by name)."""
pat = like_contains(slots["name"])
last = _last_activity_by_investor(conn)
invs = _rows(conn.execute(
"SELECT id, investor_name FROM fundraising_investors "
"WHERE investor_name LIKE ? ESCAPE '\\' ORDER BY graveyard, investor_name LIMIT 25", (pat,)))
rows = []
for inv in invs:
ts = last.get(inv["id"])
rows.append({"investor_name": inv["investor_name"], "last_activity_at": ts,
"days_since": _days_since(ts)})
return {"columns": ["investor_name", "last_activity_at", "days_since"], "rows": rows,
"truncated": False, "summary": f"Last contact for {len(rows)} investor(s) "
f"matching \"{slots['name']}\"."}
def run_comms_by_user(conn, slots):
"""The most recent `limit` outbound **investor** emails sent by a given user (matched by
username or full name). MATCHED-ONLY: restricted to investor-linked email (an
email_investor_links row exists), mirroring query_email_activity / recent_emails — NOT the
user's entire sent corpus (internal/vendor/personal mail is captured but never surfaced
here). Soft-delete-correct (live sighting, is_sent)."""
n, pat = slots["limit"], like_contains(slots["user"])
rows = _rows(conn.execute(
"SELECT e.subject, e.sent_at, u.full_name AS sender, "
"(SELECT fi.investor_name FROM email_investor_links l "
" JOIN fundraising_investors fi ON fi.id = l.fundraising_investor_id "
" WHERE l.email_id = e.id AND l.fundraising_investor_id IS NOT NULL LIMIT 1) AS investor "
"FROM emails e JOIN email_account_messages eam ON eam.email_id = e.id "
"AND eam.deleted_at IS NULL AND eam.is_sent = 1 "
"JOIN email_accounts ea ON ea.id = eam.account_id JOIN users u ON u.id = ea.user_id "
"WHERE (u.username LIKE ? ESCAPE '\\' OR u.full_name LIKE ? ESCAPE '\\') "
"AND EXISTS (SELECT 1 FROM email_investor_links l2 WHERE l2.email_id = e.id) "
"ORDER BY e.sent_at DESC LIMIT ?", (pat, pat, n)))
return {"columns": ["sent_at", "subject", "sender", "investor"], "rows": rows,
"truncated": False,
"summary": f"{len(rows)} recent email(s) sent by \"{slots['user']}\"."}
def run_email_counts_by_user(conn, slots):
"""Per-user counts of outbound **investor** emails over this week / month / year-to-date.
MATCHED-ONLY: counts only investor-linked email (an email_investor_links row exists),
mirroring query_email_activity / recent_emails — not the user's entire sent corpus.
Windows are calendar-based: week = since Monday, month = since the 1st, ytd = since Jan 1."""
today = _today()
wk = (today - timedelta(days=today.weekday())).isoformat()
mo = today.replace(day=1).isoformat()
yr = today.replace(month=1, day=1).isoformat()
where = ("WHERE eam.deleted_at IS NULL AND eam.is_sent = 1 "
"AND EXISTS (SELECT 1 FROM email_investor_links l WHERE l.email_id = e.id)")
params = [wk, mo, yr]
if slots.get("user"):
pat = like_contains(slots["user"])
where += " AND (u.username LIKE ? ESCAPE '\\' OR u.full_name LIKE ? ESCAPE '\\')"
params.extend([pat, pat])
rows = _rows(conn.execute(
"SELECT u.full_name AS user, u.username, "
"SUM(CASE WHEN substr(e.sent_at,1,10) >= ? THEN 1 ELSE 0 END) AS this_week, "
"SUM(CASE WHEN substr(e.sent_at,1,10) >= ? THEN 1 ELSE 0 END) AS this_month, "
"SUM(CASE WHEN substr(e.sent_at,1,10) >= ? THEN 1 ELSE 0 END) AS ytd "
"FROM users u JOIN email_accounts ea ON ea.user_id = u.id "
"JOIN email_account_messages eam ON eam.account_id = ea.id "
"JOIN emails e ON e.id = eam.email_id " + where +
" GROUP BY u.id HAVING ytd > 0 ORDER BY ytd DESC", params))
return {"columns": ["user", "this_week", "this_month", "ytd"], "rows": rows,
"truncated": False, "summary": f"Outbound email counts for {len(rows)} user(s)."}
# ── registry ──────────────────────────────────────────────────────────────────────────────
# key -> {summary, slots, run, example}. `slots` is consumed by the runner's validator and
# (later) surfaced to the local-model translator + the UI as the single source of truth for
# what is queryable. SlotSpec: {type: int|enum|text, ...constraints}.
INTENTS = {
"investors_cold": {
"summary": "Investors we haven't contacted in a while (default 90 days).",
"slots": {"days": {"type": "int", "default": 90, "min": 1, "max": 3650}},
"example": "Which investors haven't we reached out to in the last 3 months?",
"run": run_investors_cold,
},
"investor_lookup": {
"summary": "One investor's contacts, email, committed total and per-fund breakdown.",
"slots": {"name": {"type": "text", "required": True, "maxlen": 120}},
"example": "What is Acme Capital's email and how much have they committed across funds?",
"run": run_investor_lookup,
},
"investors_by_city": {
"summary": "Investors with a contact located in a given city.",
"slots": {"city": {"type": "text", "required": True, "maxlen": 80}},
"example": "Who are all the investors located in Austin?",
"run": run_investors_by_city,
},
"investors_by_lead": {
"summary": "Investors owned by a given lead / team member.",
"slots": {"lead": {"type": "text", "required": True, "maxlen": 80}},
"example": "Show me the investors led by Jonathan.",
"run": run_investors_by_lead,
},
"top_investors_committed": {
"summary": "Top investors by total committed capital.",
"slots": {"limit": {"type": "int", "default": 10, "min": 1, "max": MAX_ROWS}},
"example": "List our top 10 investors by committed capital.",
"run": run_top_investors_committed,
},
"investors_follow_up": {
"summary": "Investors we owe a follow-up to (have an open reminder), overdue first.",
"slots": {},
"example": "Which investors do we owe follow-ups to?",
"run": run_investors_follow_up,
},
"pipeline_top": {
"summary": "Top pipeline opportunities by stage, with investor, owner and last activity.",
"slots": {"limit": {"type": "int", "default": 10, "min": 1, "max": MAX_ROWS}},
"example": "List our top 10 pipeline investors by stage and last conversation.",
"run": run_pipeline_top,
},
"pipeline_totals": {
"summary": "Total pipeline dollars and the split across each stage.",
"slots": {},
"example": "What is our total pipeline in dollars, split by stage?",
"run": run_pipeline_totals,
},
"recent_emails": {
"summary": "The most recent investor emails (optionally inbound or outbound only).",
"slots": {"limit": {"type": "int", "default": 10, "min": 1, "max": 100},
"direction": {"type": "enum", "choices": ["any", "inbound", "outbound"],
"default": "any"}},
"example": "What were the last 10 investor emails we sent, and who to?",
"run": run_recent_emails,
},
"investor_last_contact": {
"summary": "When we last had any activity with a given investor.",
"slots": {"name": {"type": "text", "required": True, "maxlen": 120}},
"example": "When did we last reach out to Acme Capital?",
"run": run_investor_last_contact,
},
"comms_by_user": {
"summary": "Recent investor emails sent by a given team member.",
"slots": {"user": {"type": "text", "required": True, "maxlen": 80},
"limit": {"type": "int", "default": 10, "min": 1, "max": 100}},
"example": "What were the last investor emails sent by Grant?",
"run": run_comms_by_user,
},
"email_counts_by_user": {
"summary": "How many investor emails each user sent this week / month / year-to-date.",
"slots": {"user": {"type": "text", "required": False, "maxlen": 80}},
"example": "How many emails has Grant sent this week, this month, and year to date?",
"run": run_email_counts_by_user,
},
}
+127
View File
@@ -0,0 +1,127 @@
"""NL-query runner — validate a {intent, slots} request, run the curated query, return rows.
This is the trust boundary. Whatever produced the request (a local model in W2, the web UI,
or a test) is untrusted: the runner accepts ONLY a known intent key and slot VALUES, coerces
each value to its declared type, and rejects anything off-spec — it never lets a caller name
a table/column, write SQL, or choose an operator. The intents do the rest with fixed,
parameterized SQL (see intents.py). All failure modes return a structured error dict; the
runner never raises to the caller (a bad `limit=abc` must not crash the request thread).
"""
import sqlite3
from .intents import INTENTS
def _coerce_slot(name, spec, raw):
"""Coerce/validate one slot value against its spec. Returns (value, error). Exactly one
of the two is meaningful: error is None on success, else a human-readable string."""
t = spec["type"]
provided = raw is not None and not (isinstance(raw, str) and raw.strip() == "")
if not provided:
if "default" in spec:
return spec["default"], None
if spec.get("required"):
return None, f"slot '{name}' is required"
return None, None # optional, absent
if t == "int":
try:
v = int(raw)
except (TypeError, ValueError):
return None, f"slot '{name}' must be an integer (got {raw!r})"
if "min" in spec:
v = max(spec["min"], v)
if "max" in spec:
v = min(spec["max"], v)
return v, None
if t == "enum":
v = str(raw).strip().lower()
if v not in spec["choices"]:
if "default" in spec:
return spec["default"], None
return None, f"slot '{name}' must be one of {spec['choices']} (got {raw!r})"
return v, None
if t == "text":
v = str(raw).strip()
maxlen = spec.get("maxlen", 200)
if len(v) > maxlen:
v = v[:maxlen]
return v, None
return None, f"slot '{name}' has unknown type {t!r}" # registry bug, fail visibly
def validate(intent_key, raw_slots):
"""Validate an intent + raw slots WITHOUT running. Returns (clean_slots, error_dict).
Useful to the translator/UI for a dry-run check. error_dict is None on success."""
if intent_key not in INTENTS:
return None, {"error": "unknown_intent", "intent": intent_key,
"detail": f"unknown intent; known: {sorted(INTENTS)}"}
spec = INTENTS[intent_key]["slots"]
raw_slots = raw_slots or {}
# Reject unexpected slot keys rather than ignore them — a request shaped wrong is a
# misunderstanding worth surfacing, not silently dropping.
unexpected = [k for k in raw_slots if k not in spec]
if unexpected:
return None, {"error": "bad_slot", "intent": intent_key,
"detail": f"unexpected slot(s): {unexpected}; allowed: {sorted(spec)}"}
clean = {}
for name, sspec in spec.items():
v, err = _coerce_slot(name, sspec, raw_slots.get(name))
if err:
return None, {"error": "bad_slot", "intent": intent_key, "detail": err}
if v is not None or "default" in sspec:
clean[name] = v
return clean, None
def run_query(conn, intent_key, raw_slots=None, *, audit_fn=None, actor=None, source="api"):
"""Validate and execute a curated NL query. Always returns a dict — either a result
{intent, slots, columns, rows, row_count, truncated, summary} or an error
{error, intent, detail}. Records an audit row via audit_fn (if given) so a query made
through a leaked/automated credential is detectable.
audit_fn signature: audit_fn({actor, source, intent, slots, row_count, error}).
"""
clean, err = validate(intent_key, raw_slots)
if err:
if audit_fn:
try:
audit_fn({"actor": actor, "source": source, "intent": intent_key,
"slots": raw_slots, "row_count": 0, "error": err["error"]})
except Exception:
pass
return err
try:
result = INTENTS[intent_key]["run"](conn, clean)
except sqlite3.Error as exc:
# Surface a query failure (e.g. a missing optional table) as a visible error — never
# swallow it and hand back an empty result that reads as an authoritative "none".
if audit_fn:
try:
audit_fn({"actor": actor, "source": source, "intent": intent_key,
"slots": clean, "row_count": 0, "error": "query_failed"})
except Exception:
pass
return {"error": "query_failed", "intent": intent_key, "detail": str(exc)}
out = {"intent": intent_key, "slots": clean, "row_count": len(result.get("rows", [])),
**result}
if audit_fn:
try:
audit_fn({"actor": actor, "source": source, "intent": intent_key,
"slots": clean, "row_count": out["row_count"], "error": None})
except Exception:
pass
return out
def catalog():
"""The queryable surface as data: every intent's key, summary, slot specs and example.
Single source of truth for the W2 translator prompt and any UI hint list."""
return [{"intent": k, "summary": v["summary"], "slots": v["slots"],
"example": v.get("example", "")} for k, v in INTENTS.items()]
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Tests for the W2 safe NL-query runner (the model-free core).
Boots the REAL schema (server.init_db against a temp DB — exact columns + all migrations),
inserts synthetic fundraising/email/reminder/pipeline data, and exercises every intent plus
the trust-boundary behaviour:
- each intent returns the right rows over the real schema;
- SOFT-DELETE is respected on both recency legs (a tombstoned communication and a tombstoned
email sighting never count), on reminders, and on opportunities; graveyard investors are
excluded from "live" intents;
- the validator rejects bad/unknown/unexpected slots WITHOUT crashing (the `?limit=abc` class);
- LIKE wildcards in a free-text slot are escaped (a city of "%" does NOT return everything);
- limits clamp to their caps; the audit hook fires with the intent + row count.
Synthetic data only — no real LP substance, no network, no model.
Run: cd backend && python3 nl_query/test_nl_query.py
"""
import os
import sys
import tempfile
from datetime import datetime, timedelta
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
os.environ["CRM_GMAIL_INTEGRATION_ENABLED"] = "1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # backend/
import server # noqa: E402
import nl_query # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
def _ago(days):
return (datetime.utcnow() - timedelta(days=days)).isoformat() + "Z"
TODAY = datetime.utcnow().date()
def seed(conn):
c = conn.execute
# users + mailboxes
c("INSERT INTO users (id, username, email, password_hash, full_name, role) VALUES "
"('u_grant','grant','grant@ten31.xyz','x','Grant Smith','admin'),"
"('u_jon','jonathan','jon@ten31.xyz','x','Jonathan Lee','member')")
c("INSERT INTO email_accounts (id, user_id, email_address, auth_method) VALUES "
"('a_grant','u_grant','grant@ten31.xyz','dwd'),"
"('a_jon','u_jon','jon@ten31.xyz','dwd')")
# funds
c("INSERT INTO fundraising_funds (id, column_id, fund_name, display_order) VALUES "
"('f1','c_f1','Fund I',1),('f2','c_f2','Fund II',2)")
# investors (graveyard flag is the live/retired axis; no deleted_at on this table)
def inv(iid, name, lead, total, grave=0):
c("INSERT INTO fundraising_investors (id, investor_name, lead, graveyard, "
"source_row_id, total_invested) VALUES (?,?,?,?,?,?)",
(iid, name, lead, grave, iid, total))
inv("i_acme", "Acme Capital", "Jonathan Lee", 5_000_000)
inv("i_beta", "Beta Partners", "Grant Smith", 2_000_000)
inv("i_cold", "Cold Co", "Grant Smith", 0) # never contacted
inv("i_delta", "Delta LP", "Grant Smith", 1_000_000) # only a (comms) signal
inv("i_ghost", "Graveyard Ghost", "Grant Smith", 9_999_999, grave=1)
# contacts (grid pills) + classic contact rows for the comms leg
c("INSERT INTO fundraising_contacts (id, investor_id, full_name, email, title, city, "
"contact_id, sort_order) VALUES "
"('fc_a','i_acme','Alice Acme','alice@acme.com','GP','Austin','cc_alice',0),"
"('fc_b','i_beta','Bob Beta','bob@beta.com','LP','Denver',NULL,0),"
"('fc_d','i_delta','Dana Delta','dana@delta.com','CFO','Miami','cc_dana',0)")
c("INSERT INTO contacts (id, first_name, last_name, email) VALUES "
"('cc_alice','Alice','Acme','alice@acme.com'),"
"('cc_dana','Dana','Delta','dana@delta.com')")
# commitments — Acme across two funds (3M + 2M = 5M); Beta one fund
c("INSERT INTO fundraising_commitments (id, investor_id, fund_id, amount) VALUES "
"('cm1','i_acme','f1',3_000_000),('cm2','i_acme','f2',2_000_000),"
"('cm3','i_beta','f1',2_000_000)")
# emails: matched + a per-mailbox sighting. is_sent + from_email decide direction.
def email(eid, frm, frm_name, days, inv_id, account, is_sent, deleted=False):
c("INSERT INTO emails (id, rfc_message_id, from_email, from_name, sent_at, subject, "
"is_matched, match_status) VALUES (?,?,?,?,?,?,1,'matched')",
(eid, "rfc_" + eid, frm, frm_name, _ago(days), "Re: " + eid))
c("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, "
"gmail_thread_id, is_sent, deleted_at) VALUES (?,?,?,?,?,?,?)",
("eam_" + eid, eid, account, "g_" + eid, "t_" + eid, is_sent,
_ago(days) if deleted else None))
c("INSERT INTO email_investor_links (id, email_id, fundraising_investor_id, "
"matched_address, match_kind) VALUES (?,?,?,?, 'exact_email')",
("eil_" + eid, eid, inv_id, frm))
email("ea_recent", "grant@ten31.xyz", "Grant Smith", 0, "i_acme", "a_grant", 1) # Acme: today
email("eb_old", "grant@ten31.xyz", "Grant Smith", 40, "i_beta", "a_grant", 1) # Beta: 40d
email("edel", "grant@ten31.xyz", "Grant Smith", 0, "i_beta", "a_grant", 1, deleted=True) # tombstoned
email("ej", "jon@ten31.xyz", "Jonathan Lee", 0, "i_acme", "a_jon", 1) # jonathan today
email("ein", "alice@acme.com", "Alice Acme", 3, "i_acme", "a_grant", 0) # inbound 3d
# an UNMATCHED sent email by Grant (NO email_investor_links row) — captured, but not to a
# known investor. The investor-email intents are matched-only, so it must be EXCLUDED from
# comms_by_user / email_counts_by_user; without the matched-only filter it would inflate both.
c("INSERT INTO emails (id, rfc_message_id, from_email, from_name, sent_at, subject, "
"is_matched, match_status) VALUES ('eunm','rfc_eunm','grant@ten31.xyz','Grant Smith',?,"
"'Internal: team lunch',0,'unmatched')", (_ago(0),))
c("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, "
"gmail_thread_id, is_sent, deleted_at) VALUES "
"('eam_eunm','eunm','a_grant','g_eunm','t_eunm',1,NULL)")
# communications (the other recency leg) — Delta has ONLY comms: one live (5d), one tombstoned
# (today). If the soft-delete filter broke, Delta would read as contacted today.
c("INSERT INTO communications (id, contact_id, type, communication_date, created_by) VALUES "
"('cmm_live','cc_dana','email',?,'u_grant')", (_ago(5),))
c("INSERT INTO communications (id, contact_id, type, communication_date, created_by, deleted_at) "
"VALUES ('cmm_del','cc_dana','email',?,'u_grant',?)", (_ago(0), _ago(0)))
# reminders — open(overdue) / open(future) / done / deleted / standalone
def rem(rid, inv_id, title, due, status="open", deleted=False):
c("INSERT INTO reminders (id, investor_id, investor_name, title, due_date, status, "
"deleted_at) VALUES (?,?,?,?,?,?,?)",
(rid, inv_id, title, title, due, status, _ago(0) if deleted else None))
rem("r_over", "i_beta", "Send deck", (TODAY - timedelta(days=1)).isoformat()) # overdue
rem("r_future", "i_acme", "Quarterly check-in", (TODAY + timedelta(days=10)).isoformat())
rem("r_done", "i_acme", "Old task", (TODAY - timedelta(days=2)).isoformat(), status="done")
rem("r_del", "i_acme", "Tombstoned", (TODAY - timedelta(days=2)).isoformat(), deleted=True)
rem("r_standalone", None, "Team chore", (TODAY - timedelta(days=1)).isoformat())
# opportunities — committed / meeting (live) / lost (terminal) / deleted
def opp(oid, inv_id, contact, stage, expected, owner, deleted=False):
c("INSERT INTO opportunities (id, name, contact_id, stage, expected_amount, owner_id, "
"fundraising_investor_id, deleted_at) VALUES (?,?,?,?,?,?,?,?)",
(oid, oid, contact, stage, expected, owner, inv_id, _ago(0) if deleted else None))
# opp contact_id must reference a real contacts row (FK on); reuse the two we made
opp("o_acme", "i_acme", "cc_alice", "committed", 4_000_000, "u_jon")
opp("o_beta", "i_beta", "cc_dana", "meeting", 1_000_000, "u_grant")
opp("o_lost", "i_acme", "cc_alice", "lost", 9_000_000, "u_jon")
opp("o_del", "i_beta", "cc_dana", "due_diligence", 7_000_000, "u_grant", deleted=True)
conn.commit()
def names(res):
return [r["investor_name"] for r in res["rows"]]
def main():
server.init_db()
conn = server.get_db()
seed(conn)
run = lambda *a, **k: nl_query.run_query(conn, *a, **k)
print("investors_cold")
r = run("investors_cold", {"days": 30})
check(names(r) == ["Cold Co", "Beta Partners"], f"cold(30) never-first then stale: {names(r)}")
check(run("investors_cold", {"days": 90})["row_count"] == 1, "cold(90): only never-contacted")
check("Graveyard Ghost" not in names(run("investors_cold", {"days": 3650})),
"cold excludes graveyard investors")
check("Delta LP" in names(run("investors_cold", {"days": 3})), "cold(3) sees Delta (comms 5d)")
check("Delta LP" not in names(run("investors_cold", {"days": 7})),
"cold(7): Delta's tombstoned comm (today) did NOT count")
print("investor_lookup")
r = run("investor_lookup", {"name": "acme"})
check(r["row_count"] == 1 and r["rows"][0]["total_invested"] == 5_000_000, "lookup total committed")
check({c["fund_name"] for c in r["rows"][0]["commitments"]} == {"Fund I", "Fund II"},
"lookup per-fund breakdown")
check(r["rows"][0]["contacts"][0]["email"] == "alice@acme.com", "lookup surfaces contact email")
print("investors_by_city / by_lead / top / follow_up")
check(names(run("investors_by_city", {"city": "Austin"})) == ["Acme Capital"], "by_city")
check(set(names(run("investors_by_lead", {"lead": "Grant"}))) == {"Beta Partners", "Cold Co", "Delta LP"},
"by_lead excludes graveyard + other leads")
check(names(run("top_investors_committed", {"limit": 2})) == ["Acme Capital", "Beta Partners"],
"top by committed (graveyard + zero excluded)")
r = run("investors_follow_up")
check(names(r) == ["Beta Partners", "Acme Capital"], f"follow_up overdue-first, open-only: {names(r)}")
check(r["rows"][0]["overdue"] == 1 and r["rows"][1]["overdue"] == 0, "follow_up overdue flag")
print("pipeline")
r = run("pipeline_totals")
stages = {row["stage"]: row for row in r["rows"]}
check(set(stages) == {"committed", "meeting"}, f"pipeline_totals excludes lost+deleted: {set(stages)}")
check(stages["committed"]["expected_total"] == 4_000_000, "pipeline_totals stage sum")
r = run("pipeline_top", {"limit": 10})
check(names(r) == ["Acme Capital", "Beta Partners"], "pipeline_top furthest-stage first")
check(r["rows"][0]["last_activity_at"] is not None, "pipeline_top enriches last activity")
print("emails")
check(run("recent_emails", {"direction": "outbound"})["row_count"] == 3,
"recent_emails(outbound): 3 live (tombstoned sighting excluded)")
check(run("recent_emails", {"direction": "inbound"})["row_count"] == 1, "recent_emails(inbound)")
check(run("recent_emails")["row_count"] == 4, "recent_emails(any): 4 live")
r = run("investor_last_contact", {"name": "beta"})
check(r["rows"][0]["days_since"] >= 39, "investor_last_contact days_since")
check(run("comms_by_user", {"user": "Grant"})["row_count"] == 2,
"comms_by_user: grant's 2 live MATCHED outbound (tombstoned + unmatched excluded)")
r = run("email_counts_by_user", {"user": "grant"})
check(r["rows"][0]["this_week"] == 1,
"email_counts this_week = 1 live matched (tombstoned + unmatched excluded)")
check(r["rows"][0]["ytd"] >= 1, "email_counts ytd")
print("trust boundary")
check(run("investors_cold", {"days": "abc"})["error"] == "bad_slot", "bad int slot -> bad_slot, no crash")
check(run("nope")["error"] == "unknown_intent", "unknown intent rejected")
check(run("pipeline_totals", {"foo": 1})["error"] == "bad_slot", "unexpected slot rejected")
check(run("investor_lookup", {})["error"] == "bad_slot", "missing required slot rejected")
check(run("investors_by_city", {"city": "%"})["row_count"] == 0,
"LIKE wildcard escaped — '%' does not match every row")
check(run("investors_cold", {"days": 0})["slots"]["days"] == 1, "int slot clamps to min")
check(run("top_investors_committed", {"limit": 99999})["slots"]["limit"] == nl_query.INTENTS
["top_investors_committed"]["slots"]["limit"]["max"], "int slot clamps to max")
print("audit hook + catalog")
seen = []
run("pipeline_totals", audit_fn=seen.append, actor="tester", source="test")
check(len(seen) == 1 and seen[0]["intent"] == "pipeline_totals" and seen[0]["error"] is None
and seen[0]["actor"] == "tester", "audit hook fires with intent/actor/no-error")
run("nope", audit_fn=seen.append)
check(seen[-1]["error"] == "unknown_intent", "audit hook fires on rejection too")
check(len(nl_query.catalog()) == len(nl_query.INTENTS), "catalog covers every intent")
conn.close()
print()
if FAILS:
print(f"{len(FAILS)} FAILED")
for f in FAILS:
print(" - " + f)
sys.exit(1)
print("ALL PASS")
if __name__ == "__main__":
main()
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Endpoint tests for the W2 NL-query HTTP surface (POST /api/query/nl, GET /api/query/catalog).
Boots the REAL server against a temp DB and exercises the wiring end-to-end: auth gating
(bot/admin only), the direct {intent, slots} mode, the soft-error shape, and the status
mapping. The local model is forced UNAVAILABLE by pointing SPARK_CONTROL_URL at a dead local
port, so the {question} path exercises the 503 path deterministically without any Spark.
Synthetic data only.
Run: cd backend && python3 nl_query/test_nl_query_endpoint.py
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
os.environ["CRM_GMAIL_INTEGRATION_ENABLED"] = "1"
# Dead port -> the local-model leg fails fast, so the {question} path returns 503 deterministically
# (set before server/config import; load_env uses setdefault so this wins over any repo .env).
os.environ["SPARK_CONTROL_URL"] = "http://127.0.0.1:1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # backend/
import server # noqa: E402
import nl_query # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
class _Quiet(server.CRMHandler):
def log_message(self, *a):
pass
def _req(port, method, path, token=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
headers = {}
if token:
headers["Authorization"] = "Bearer " + token
payload = json.dumps(body) if body is not None else None
if payload is not None:
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
data = json.loads(raw) if raw else None
return resp.status, data
def _data(d):
return (d or {}).get("data") or {}
def main():
server.init_db()
db = sqlite3.connect(os.environ["CRM_DB_PATH"])
db.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) VALUES "
"('u_admin','grant','g@t.x','x','Grant','admin',1),"
"('u_mem','mem','m@t.x','x','Mem','member',1)")
db.execute("INSERT INTO fundraising_investors (id,investor_name,lead,graveyard,source_row_id,"
"total_invested) VALUES ('a','Acme Capital','Jon',0,'a',5000000),"
"('b','Beta Partners','Grant',0,'b',2000000),('g','Ghost','Grant',1,'g',9000000)")
db.commit()
db.close()
admin = server.create_token("u_admin", "grant", "admin")
member = server.create_token("u_mem", "mem", "member")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
print("direct {intent, slots} mode")
st, d = _req(port, "POST", "/api/query/nl", admin,
{"intent": "top_investors_committed", "slots": {"limit": 2}})
rows = _data(d).get("rows", [])
check(st == 200 and [r["investor_name"] for r in rows] == ["Acme Capital", "Beta Partners"],
f"admin direct query -> 200 + rows (got {st})")
check(_data(d).get("intent") == "top_investors_committed", "response echoes interpreted intent")
print("soft errors + validation")
st, d = _req(port, "POST", "/api/query/nl", admin, {"intent": "made_up"})
check(st == 200 and _data(d).get("error") == "unknown_intent",
f"bad intent -> 200 with data.error=unknown_intent (got {st}, {_data(d).get('error')})")
st, d = _req(port, "POST", "/api/query/nl", admin, {})
check(st == 400, f"neither question nor intent -> 400 (got {st})")
print("auth gating")
st, _ = _req(port, "POST", "/api/query/nl", member,
{"intent": "top_investors_committed"})
check(st == 403, f"member -> 403 (got {st})")
st, _ = _req(port, "POST", "/api/query/nl", None, {"intent": "top_investors_committed"})
check(st == 401, f"unauthenticated -> 401 (got {st})")
print("catalog")
st, d = _req(port, "GET", "/api/query/catalog", admin)
check(st == 200 and isinstance(d.get("data"), list) and len(d["data"]) == len(nl_query.INTENTS),
f"catalog -> 200 with every intent (got {st})")
st, _ = _req(port, "GET", "/api/query/catalog", member)
check(st == 403, f"catalog member -> 403 (got {st})")
print("question path with the local model down")
st, d = _req(port, "POST", "/api/query/nl", admin,
{"question": "who are our top investors by committed capital?"})
check(st == 503 and _data(d).get("error") == "model_unavailable",
f"question + dead model -> 503 model_unavailable (got {st}, {_data(d).get('error')})")
check(_data(d).get("question"), "question echoed back even on outage")
print("audit trail")
db = sqlite3.connect(os.environ["CRM_DB_PATH"])
n = db.execute("SELECT COUNT(*) FROM audit_log WHERE entity_type='nl_query'").fetchone()[0]
db.close()
check(n >= 2, f"executed queries are audited (entity_type=nl_query rows: {n})")
finally:
httpd.shutdown()
print()
if FAILS:
print(f"{len(FAILS)} FAILED")
for f in FAILS:
print(" - " + f)
sys.exit(1)
print("ALL PASS")
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Tests for the W2 NL translator (question -> {intent, slots}) — the local-model leg.
The model is stubbed via an injected chat_fn, so this runs fully offline (no Spark, no
network). Covers:
- build_system() exposes the whole intent catalog as the model's closed vocabulary;
- translate() returns the parsed {intent, slots} and DROPS slot keys the intent doesn't
declare (model noise), while every surviving value is still validated downstream;
- the translation failure modes: no intent fit -> no_match; unparseable -> no_match;
local model unreachable -> model_unavailable (so the endpoint can 503);
- answer() chains translate + the validated runner end-to-end, and a HALLUCINATED intent
from the model is still rejected by the validator (the model output is never trusted).
Run: cd backend && python3 nl_query/test_translate.py
"""
import os
import sys
import tempfile
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # backend/
import server # noqa: E402
import nl_query # noqa: E402
T = nl_query # exercise the public API (translate/answer/build_system are re-exported)
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
def main():
print("build_system")
sysprompt = nl_query.build_system()
check(all(k in sysprompt for k in nl_query.INTENTS), "system prompt lists every intent key")
check("days (integer, default 90)" in sysprompt, "system prompt renders int slot + default")
check("one of any|inbound|outbound" in sysprompt, "system prompt renders enum choices")
print("translate")
captured = {}
def fake(prompt, system):
captured["system"] = system
captured["prompt"] = prompt
return {"intent": "investors_cold", "slots": {"days": 90, "bogus": "x"}}
r = T.translate("who's gone quiet for 3 months?", chat_fn=fake)
check(r == {"intent": "investors_cold", "slots": {"days": 90}},
f"routes to intent + drops unknown slot 'bogus': {r}")
check(nl_query.INTENTS and "investors_cold" in captured["system"], "chat_fn received the catalog")
check(captured["prompt"] == "who's gone quiet for 3 months?", "chat_fn received the question")
check(T.translate("x", chat_fn=lambda q, s: {"intent": None})["error"] == "no_match",
"intent null -> no_match")
check(T.translate("x", chat_fn=lambda q, s: None)["error"] == "no_match",
"unparseable model reply -> no_match")
check(T.translate("", chat_fn=lambda q, s: {"intent": "x"})["error"] == "no_match",
"empty question -> no_match (no model call needed)")
def boom(q, s):
raise RuntimeError("spark down")
check(T.translate("x", chat_fn=boom)["error"] == "model_unavailable",
"local model unreachable -> model_unavailable")
print("answer (end-to-end through the validated runner)")
server.init_db()
conn = server.get_db()
conn.execute("INSERT INTO fundraising_investors (id, investor_name, lead, graveyard, "
"source_row_id, total_invested) VALUES "
"('a','Acme Capital','Jon',0,'a',5000000),"
"('b','Beta Partners','Grant',0,'b',2000000),"
"('g','Ghost','Grant',1,'g',9000000)")
conn.commit()
r = T.answer(conn, "top investors",
chat_fn=lambda q, s: {"intent": "top_investors_committed", "slots": {"limit": 2}})
check([x["investor_name"] for x in r["rows"]] == ["Acme Capital", "Beta Partners"],
"answer() runs the translated query")
check(r["question"] == "top investors", "answer() echoes the original question")
r = T.answer(conn, "nonsense", chat_fn=lambda q, s: {"intent": "made_up_intent", "slots": {}})
check(r.get("error") == "unknown_intent", "hallucinated intent is rejected by the validator")
check(r["question"] == "nonsense", "answer() echoes question on error too")
r = T.answer(conn, "anything", chat_fn=boom)
check(r.get("error") == "model_unavailable", "answer() surfaces a model outage")
conn.close()
print()
if FAILS:
print(f"{len(FAILS)} FAILED")
for f in FAILS:
print(" - " + f)
sys.exit(1)
print("ALL PASS")
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
"""NL-query translator — plain-English question -> {intent, slots} on the LOCAL model.
The model's ONLY job is to pick one curated intent and fill its typed slots; it never
touches the database, never sees a row, and never writes SQL. Its output is untrusted and
is handed straight to the runner's validator (runner.validate), which is the trust boundary.
LOCAL-ONLY BY CONSTRUCTION. Translation runs on the local Qwen via Spark Control
(SPARK_CONTROL_URL), the same sanctioned local leg as intake/digest — so the question never
leaves the box and there is NO Claude path and NO redaction boundary to manage here (that
was the whole point of the W2 simplification: the answer is sensitive and never leaves; the
question is generic English and is translated locally). If the local model ever proves too
weak, a Claude-behind-redaction translator could be slotted in as an alternative `chat_fn`
WITHOUT changing the validator/executor — but it is deliberately not built.
`chat_fn(prompt, system) -> dict|None` is injectable so the whole translation leg is testable
offline without Spark. The default calls the ingest Spark client (lazy import — it ships in
the Docker image, not the bare CRM).
"""
from .intents import INTENTS
from .runner import run_query
def _default_chat_json(prompt, system):
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ingest"))
import llm # noqa: E402 (ingest Spark client; raises if Spark is unreachable)
return llm.chat_json(prompt, system=system, max_tokens=400)
def _render_slot(name, spec):
t = spec["type"]
if t == "int":
extra = f", default {spec['default']}" if "default" in spec else ""
return f"{name} (integer{extra})"
if t == "enum":
extra = f", default {spec['default']}" if "default" in spec else ""
return f"{name} (one of {'|'.join(spec['choices'])}{extra})"
req = ", required" if spec.get("required") else ", optional"
return f"{name} (text{req})"
def build_system():
"""The system prompt: the full intent catalog as the model's closed vocabulary."""
lines = [
"You translate a question about a venture fund's investor database into ONE "
"structured query. Respond with ONLY a JSON object and nothing else:",
' {"intent": "<one key below, or null>", "slots": {<slot>: <value>}}',
"",
"Rules:",
"- Choose the single best-fitting intent. If none fits, return {\"intent\": null}.",
"- Use ONLY the slot names listed for the chosen intent; omit a slot to accept its default.",
"- Convert natural durations to the integer a slot wants: '3 months'->90, 'a quarter'->90, "
"'6 weeks'->42, 'a year'/'year to date'->365.",
"- Copy names, cities and people verbatim from the question into text slots.",
"- No commentary, no markdown, JSON only.",
"",
"Intents:",
]
for key, spec in INTENTS.items():
slots = spec["slots"]
slot_str = "; ".join(_render_slot(n, s) for n, s in slots.items()) or "(none)"
lines.append(f"- {key}: {spec['summary']}")
lines.append(f" slots: {slot_str}")
if spec.get("example"):
lines.append(f" e.g. \"{spec['example']}\"")
return "\n".join(lines)
def translate(question, *, chat_fn=None):
"""Map a question to {intent, slots} on the local model. Returns that dict, or an error
dict {error, detail}: 'model_unavailable' (local model unreachable -> the endpoint 503s)
or 'no_match' (the model could not map the question to any intent)."""
chat_fn = chat_fn or _default_chat_json
q = (question or "").strip()
if not q:
return {"error": "no_match", "detail": "empty question"}
try:
data = chat_fn(q, build_system())
except Exception as exc: # connection/runtime failure on the LOCAL model
return {"error": "model_unavailable", "detail": str(exc)}
if not isinstance(data, dict):
return {"error": "no_match", "detail": "model returned no parseable JSON"}
intent = data.get("intent")
if intent in (None, "", "null", "none"):
return {"error": "no_match", "detail": "no intent fit the question"}
slots = data.get("slots")
slots = slots if isinstance(slots, dict) else {}
# Drop slot KEYS the chosen intent doesn't declare — model noise, not a safety concern
# (every surviving VALUE still goes through full type validation in the runner). Unknown
# intents are left as-is so the runner rejects them as unknown_intent.
if intent in INTENTS:
allowed = INTENTS[intent]["slots"]
slots = {k: v for k, v in slots.items() if k in allowed}
return {"intent": intent, "slots": slots}
def answer(conn, question, *, chat_fn=None, audit_fn=None, actor=None, source="api"):
"""End-to-end: translate a question locally, then run it through the validated runner.
Returns the runner's result (with the interpreted intent/slots, so a human can see how
the question was read) plus the original question, or a translation error dict."""
t = translate(question, chat_fn=chat_fn)
if t.get("error"):
return {**t, "question": question}
result = run_query(conn, t["intent"], t["slots"],
audit_fn=audit_fn, actor=actor, source=source)
result["question"] = question
return result
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Dev harness — fire questions at the LOCAL model and print how each is translated.
Lets you eyeball whether the local Qwen maps real questions to the right curated query
(intent + slots), against your real Spark, with NO UI, auth, HTTP, or deploy. This is the
cheap way to validate translation quality before building the web/Matrix surfaces. It only
translates (it does not touch the DB), so no data is needed and nothing leaves the box.
NOT shipped and NOT a test (no `test_` prefix) — a developer convenience.
Needs SPARK_CONTROL_URL set (read from the repo .env) and the Spark reachable.
Run:
python3 backend/nl_query/try_questions.py # the built-in sample set
python3 backend/nl_query/try_questions.py "when did we last email Acme?"
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # backend/
import nl_query # noqa: E402
SAMPLES = [
"Which investors haven't we reached out to in the last 3 months?",
"Which investors do we owe follow-ups to?",
"What is Acme Capital's email and how much have they committed across funds?",
"When did we last reach out to Acme Capital?",
"What were the last 10 investor emails we sent, and who to?",
"What were the last 10 investor emails we received?",
"Who are all the investors located in Austin?",
"List our top 10 investors by committed capital.",
"List our top 10 pipeline investors by stage and the most recent conversation.",
"What is our total pipeline in dollars, split by stage?",
"What were the last investor emails sent by Grant?",
"How many emails has Jonathan sent this week, this month, and year to date?",
]
def main():
questions = sys.argv[1:] or SAMPLES
print(f"Translating {len(questions)} question(s) on the local model "
f"(SPARK_CONTROL_URL={os.environ.get('SPARK_CONTROL_URL', '(unset)')})\n")
for q in questions:
r = nl_query.translate(q)
if r.get("error"):
print(f" ? {q}\n -> [{r['error']}] {r.get('detail', '')}\n")
else:
print(f" ? {q}\n -> {r['intent']} slots={r['slots']}\n")
if __name__ == "__main__":
main()
+1066 -214
View File
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Regression test for the dashboard KPI repoint + lp_profiles retirement (2026-06-16).
"Total Committed" used to SUM lp_profiles.commitment_amount — an orphaned table with no
reachable input path, so the dashboard read ~$0 while the real commitments lived in the
fundraising grid. It now sums fundraising_investors.total_invested (the canonical grid
rollup) with graveyarded (written-off) investors excluded, "Total Funded" is dropped
(the grid has no funded-vs-committed concept), and the /api/lp-profiles* + lp-breakdown
endpoints are gone.
This boots the REAL server against a temp DB, seeds two grid investors (one live, one
graveyarded), and asserts: total_committed reflects the live grid rollup only, the
metrics no longer carry a total_funded key, and the retired routes 404. Synthetic only.
Run: cd backend && python3 test_dashboard_report.py
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import server # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
class _Quiet(server.CRMHandler):
def log_message(self, *a):
pass
def _get(port, path, token):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
conn.request("GET", path, headers={"Authorization": "Bearer " + token})
resp = conn.getresponse()
body = resp.read().decode("utf-8", "replace")
conn.close()
data = None
if body:
try:
data = json.loads(body)
except ValueError:
pass
return resp.status, data
def seed():
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
# live investor committed 3,000,000; graveyarded investor committed 500,000 (must be excluded)
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
"VALUES ('fiLive','Harbor LP','rowLive',3000000,0)")
c.execute("INSERT INTO fundraising_investors (id,investor_name,source_row_id,total_invested,graveyard) "
"VALUES ('fiDead','Passed LP','rowDead',500000,1)")
c.commit()
c.close()
def main():
server.init_db()
seed()
token = server.create_token("u1", "grant", "admin")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
print("\n[dashboard total_committed comes from the grid, graveyard excluded]")
st, dash = _get(port, "/api/reports/dashboard", token)
check(st == 200, f"GET dashboard -> 200 (got {st})")
metrics = (dash or {}).get("data", {}).get("metrics", {})
check(metrics.get("total_committed") == 3000000,
f"total_committed = live grid rollup only (3,000,000; got {metrics.get('total_committed')})")
check("total_funded" not in metrics,
f"total_funded key dropped from metrics (got keys {sorted(metrics)})")
print("\n[retired lp_profiles endpoints 404]")
for path in ("/api/lp-profiles", "/api/lp-profiles/anything", "/api/reports/lp-breakdown"):
st, _ = _get(port, path, token)
check(st == 404, f"GET {path} -> 404 (got {st})")
finally:
httpd.shutdown()
print()
if FAILS:
print(f"FAILED ({len(FAILS)}):")
for f in FAILS:
print(f" - {f}")
sys.exit(1)
print("ALL PASS (dashboard KPI repoint + lp_profiles retirement)")
if __name__ == "__main__":
main()
+116
View File
@@ -193,6 +193,55 @@ def test_build_and_empty():
conn.close() conn.close()
def test_reminders_due():
"""The reminders-due section: overdue + due-today only (future / done / soft-deleted
excluded), rendered even on an empty email window. Creates + drops the reminders table
so the rest of the suite still exercises the table-absent path."""
from datetime import date, timedelta
conn = _conn()
conn.execute("""CREATE TABLE reminders (id TEXT PRIMARY KEY, investor_id TEXT,
investor_name TEXT, contact_id TEXT, title TEXT, details TEXT, due_date TEXT,
status TEXT DEFAULT 'open', snoozed_until TEXT, assignee_id TEXT, created_by TEXT,
source TEXT, completed_at TEXT, created_at TEXT, updated_at TEXT, deleted_at TEXT)""")
today = date.today().isoformat()
yest = (date.today() - timedelta(days=1)).isoformat()
future = (date.today() + timedelta(days=30)).isoformat()
conn.executemany(
"INSERT INTO reminders (id,investor_name,title,due_date,status,assignee_id,deleted_at) "
"VALUES (?,?,?,?,?,?,?)", [
("r1", "Harbor & Vine", "Send wire instructions", yest, "open", "u1", None), # overdue
("r2", "Brightwater Capital", "Call about allocation", today, "open", None, None), # due today
("r3", "Vela Partners", "Quarterly touch", future, "open", "u1", None), # future -> hidden
("r4", "Gone LP", "Done already", yest, "done", "u1", None), # done -> hidden
("r5", "Deleted LP", "Tombstoned", yest, "open", "u1", "2026-06-01T00:00:00Z"), # deleted -> hidden
])
conn.commit()
due = digest_builder.collect_due_reminders(conn, today)
titles = {r["title"] for r in due}
check(titles == {"Send wire instructions", "Call about allocation"},
f"due collector = overdue + due-today only (got {titles})")
overdue = [r for r in due if r["overdue"]]
check(len(overdue) == 1 and overdue[0]["title"] == "Send wire instructions", "overdue flagged")
stub = lambda prompt, system=None, max_tokens=220: "narrative"
d = digest_builder.build_digest(conn, SINCE, UNTIL, chat_fn=stub)
check(d["reminder_count"] == 2, f"reminder_count = 2 (got {d['reminder_count']})")
check("REMINDERS DUE (2)" in d["body"], "body has reminders section header")
check("Overdue (1):" in d["body"] and "Due today (1):" in d["body"], "body splits overdue / due today")
check("Harbor & Vine — Send wire instructions" in d["body"]
and "[Grant Gilliam]" in d["body"], "reminder line shows investor + title + resolved assignee")
check("Quarterly touch" not in d["body"], "future reminder excluded from due section")
empty = digest_builder.build_digest(conn, "2030-01-01T00:00:00Z", "2030-01-02T00:00:00Z", chat_fn=stub)
check("No tracked email activity" in empty["body"] and "REMINDERS DUE (2)" in empty["body"],
"reminders render even on an empty email window (current-state addendum)")
conn.execute("DROP TABLE reminders")
conn.commit()
conn.close()
def test_policy(): def test_policy():
conn = _conn() conn = _conn()
# No DB row yet: CRM_DIGEST_ENABLED=1 (set at import) seeds enabled; hour defaults 18. # No DB row yet: CRM_DIGEST_ENABLED=1 (set at import) seeds enabled; hour defaults 18.
@@ -283,13 +332,80 @@ def test_scheduler_guards():
conn.close() conn.close()
def test_window_resolver():
from datetime import timedelta
nu = datetime(2026, 6, 16, 15, 0, tzinfo=timezone.utc)
nl = datetime(2026, 6, 16, 8, 0, tzinfo=timezone(timedelta(hours=-7))) # PDT
s, u = digest_builder.resolve_digest_window(now_utc=nu, now_local=nl)
check((s, u) == ("2026-06-15T15:00:00Z", "2026-06-16T15:00:00Z"), f"default = last 24h, got {(s,u)}")
s, u = digest_builder.resolve_digest_window(hours=48, now_utc=nu, now_local=nl)
check(s == "2026-06-14T15:00:00Z", f"hours=48 lookback, got {s}")
# since = a local calendar date -> that day's LOCAL midnight, expressed in UTC
s, u = digest_builder.resolve_digest_window(since="2026-06-01", now_utc=nu, now_local=nl)
check(s == "2026-06-01T07:00:00Z", f"since-date -> local midnight in UTC, got {s}")
# a since older than the cap clamps to MAX_WINDOW_DAYS (response echoes real window)
s, u = digest_builder.resolve_digest_window(since="2025-01-01", now_utc=nu, now_local=nl)
check(s == (nu - timedelta(days=digest_builder.MAX_WINDOW_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ"),
f"over-cap since clamps to {digest_builder.MAX_WINDOW_DAYS}d, got {s}")
# since wins over hours when both supplied
s, u = digest_builder.resolve_digest_window(hours=1, since="2026-06-10", now_utc=nu, now_local=nl)
check(s.startswith("2026-06-10"), f"since wins over hours, got {s}")
# same-day boundary: since = today's local date, now later in the day -> valid
# window (local midnight is strictly before now), not a "start must be before now" raise
s, u = digest_builder.resolve_digest_window(since="2026-06-16", now_utc=nu, now_local=nl)
check(s == "2026-06-16T07:00:00Z" and u == "2026-06-16T15:00:00Z",
f"since=today -> [local midnight, now], got {(s, u)}")
for bad in [dict(hours=0), dict(hours="abc"), dict(since="nope"), dict(since="2027-01-01")]:
try:
digest_builder.resolve_digest_window(now_utc=nu, now_local=nl, **bad)
check(False, f"bad input {bad} should raise")
except ValueError:
check(True, f"bad input rejected: {bad}")
def test_send_digest_window():
sent = []
build_fn = lambda conn, since, until: {"subject": "S", "body": f"{since}|{until}",
"has_activity": True, "user_count": 1,
"email_count": 2, "investor_count": 1}
def send_fn(conn, to_addrs, subject, body, sender_email=None):
sent.append((list(to_addrs), body))
return {"transport": "stub"}
conn = _conn()
before = digest_scheduler._get_setting(conn, digest_scheduler._LAST_AT_KEY)
conn.close()
r = digest_scheduler.send_digest_window(_conn, since_iso="2026-05-01T00:00:00Z",
until_iso="2026-06-16T00:00:00Z",
build_fn=build_fn, send_fn=send_fn)
check(r["status"] == "sent" and r["window"] == ["2026-05-01T00:00:00Z", "2026-06-16T00:00:00Z"],
f"windowed send returns its window, got {r}")
check(sent and sent[-1][0] == ["grant@ten31.xyz"], f"windowed send -> active admins only, got {sent}")
conn = _conn()
after = digest_scheduler._get_setting(conn, digest_scheduler._LAST_AT_KEY)
conn.close()
check(before == after, "windowed manual send does not advance the daily cursor")
def main(): def main():
setup() setup()
print("collect_user_activity:"); test_collect() print("collect_user_activity:"); test_collect()
print("collect_investor_activity:"); test_investor() print("collect_investor_activity:"); test_investor()
print("build_digest + empty:"); test_build_and_empty() print("build_digest + empty:"); test_build_and_empty()
print("reminders due:"); test_reminders_due()
print("summary fallback:"); test_summary_fallback() print("summary fallback:"); test_summary_fallback()
print("digest policy:"); test_policy() print("digest policy:"); test_policy()
print("window resolver:"); test_window_resolver()
print("windowed manual send:"); test_send_digest_window()
print("scheduler guards:"); test_scheduler_guards() print("scheduler guards:"); test_scheduler_guards()
if FAILS: if FAILS:
print(f"\nFAILED ({len(FAILS)})") print(f"\nFAILED ({len(FAILS)})")
+8 -6
View File
@@ -33,7 +33,7 @@ def setup():
conn.executescript(""" conn.executescript("""
CREATE TABLE app_settings (key TEXT PRIMARY KEY, value_json TEXT, updated_at TEXT); CREATE TABLE app_settings (key TEXT PRIMARY KEY, value_json TEXT, updated_at TEXT);
CREATE TABLE email_accounts (id TEXT, email_address TEXT, sync_enabled INT DEFAULT 1, sync_status TEXT, backfill_complete INT); CREATE TABLE email_accounts (id TEXT, email_address TEXT, sync_enabled INT DEFAULT 1, sync_status TEXT, backfill_complete INT);
CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, snippet TEXT, from_email TEXT, sent_at TEXT, is_matched INT, match_status TEXT); CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, snippet TEXT, from_name TEXT, from_email TEXT, sent_at TEXT, is_matched INT, match_status TEXT);
CREATE TABLE email_investor_links (id TEXT, email_id TEXT, fundraising_investor_id TEXT, organization_id TEXT, contact_id TEXT, match_confidence REAL); CREATE TABLE email_investor_links (id TEXT, email_id TEXT, fundraising_investor_id TEXT, organization_id TEXT, contact_id TEXT, match_confidence REAL);
CREATE TABLE email_activity_proposals (id TEXT PRIMARY KEY, email_id TEXT UNIQUE, investor_id TEXT, investor_name TEXT, CREATE TABLE email_activity_proposals (id TEXT PRIMARY KEY, email_id TEXT UNIQUE, investor_id TEXT, investor_name TEXT,
direction TEXT, summary TEXT, proposed_note TEXT, email_subject TEXT, email_date TEXT, status TEXT DEFAULT 'pending', direction TEXT, summary TEXT, proposed_note TEXT, email_subject TEXT, email_date TEXT, status TEXT DEFAULT 'pending',
@@ -51,10 +51,10 @@ def setup():
grid = {"columns": [], "rows": [{"id": "inv1", "investor_name": "Harbor & Vine", "notes": "existing note"}]} grid = {"columns": [], "rows": [{"id": "inv1", "investor_name": "Harbor & Vine", "notes": "existing note"}]}
conn.execute("INSERT INTO fundraising_state (id,grid_json,views_json,version) VALUES ('main',?,?,1)", (json.dumps(grid), "[]")) conn.execute("INSERT INTO fundraising_state (id,grid_json,views_json,version) VALUES ('main',?,?,1)", (json.dumps(grid), "[]"))
# e1 sent (from us), e2 received, both after cutoff; e3 before cutoff (excluded) # e1 sent (from us), e2 received, both after cutoff; e3 before cutoff (excluded)
conn.executemany("INSERT INTO emails (id,subject,body_text,from_email,sent_at,is_matched,match_status) VALUES (?,?,?,?,?,1,'matched')", [ conn.executemany("INSERT INTO emails (id,subject,body_text,from_name,from_email,sent_at,is_matched,match_status) VALUES (?,?,?,?,?,?,1,'matched')", [
("e1", "Fund III", "Here is the update", "grant@ten31.xyz", "2026-06-01T10:00:00"), ("e1", "Fund III", "Here is the update", "Grant", "grant@ten31.xyz", "2026-06-01T10:00:00"),
("e2", "Re: Fund III", "Thanks, a question", "lp@harborvine.example", "2026-06-02T10:00:00"), ("e2", "Re: Fund III", "Thanks, a question", "Harbor LP", "lp@harborvine.example", "2026-06-02T10:00:00"),
("e3", "Old", "ancient", "lp@harborvine.example", "2025-01-01T10:00:00"), ("e3", "Old", "ancient", "Harbor LP", "lp@harborvine.example", "2025-01-01T10:00:00"),
]) ])
conn.executemany("INSERT INTO email_investor_links (id,email_id,fundraising_investor_id,match_confidence) VALUES (?,?, 'inv1', 1.0)", conn.executemany("INSERT INTO email_investor_links (id,email_id,fundraising_investor_id,match_confidence) VALUES (?,?, 'inv1', 1.0)",
[("l1", "e1"), ("l2", "e2"), ("l3", "e3")]) [("l1", "e1"), ("l2", "e2"), ("l3", "e3")])
@@ -77,7 +77,9 @@ def main():
dirs = sorted(p["direction"] for p in props) dirs = sorted(p["direction"] for p in props)
check(dirs == ["received", "sent"], f"directions sent+received, got {dirs}") check(dirs == ["received", "sent"], f"directions sent+received, got {dirs}")
e1 = next(p for p in props if p["email_id"] == "e1") e1 = next(p for p in props if p["email_id"] == "e1")
check(e1["direction"] == "sent" and "Sent" in e1["proposed_note"], "e1 (from us) is 'sent'") check(e1["direction"] == "sent" and "Grant emailed Harbor & Vine" in e1["proposed_note"], "e1 (from us) names sender + investor")
e2 = next(p for p in props if p["email_id"] == "e2")
check(e2["direction"] == "received" and "emailed the team" in e2["proposed_note"], "e2 (inbound) reads '<sender> emailed the team'")
check("" in e1["proposed_note"] and "fundraising update" in e1["proposed_note"], "proposed note marked + has gist") check("" in e1["proposed_note"] and "fundraising update" in e1["proposed_note"], "proposed note marked + has gist")
# grid must be UNTOUCHED before approval # grid must be UNTOUCHED before approval
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Test the Matrix review-bot bridge for email-activity proposals (Features 2/3):
the bot work-lists (to_post / open / to_close), the Matrix side-row mark helpers, and an
in-thread (source='matrix') decision that closes the thread — plus the bot-or-admin role gate.
Synthetic data only (guardrail #9). The local model is stubbed.
Run: cd backend && python3 test_email_proposal_matrix.py
"""
import json
import os
import sqlite3
import sys
import tempfile
os.environ["CRM_DB_PATH"] = os.path.join(tempfile.mkdtemp(), "crm.db")
os.environ.setdefault("CRM_DATA_DIR", os.path.dirname(os.environ["CRM_DB_PATH"]))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import server # noqa: E402
server._summarize_email_gist = lambda subject, body: "fundraising update; proposed a call"
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
def setup():
conn = sqlite3.connect(os.environ["CRM_DB_PATH"])
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE app_settings (key TEXT PRIMARY KEY, value_json TEXT, updated_at TEXT);
CREATE TABLE email_accounts (id TEXT, email_address TEXT, sync_enabled INT DEFAULT 1, sync_status TEXT, backfill_complete INT);
CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, snippet TEXT, from_name TEXT, from_email TEXT, sent_at TEXT, is_matched INT, match_status TEXT);
CREATE TABLE email_investor_links (id TEXT, email_id TEXT, fundraising_investor_id TEXT, organization_id TEXT, contact_id TEXT, match_confidence REAL);
CREATE TABLE email_activity_proposals (id TEXT PRIMARY KEY, email_id TEXT UNIQUE, investor_id TEXT, investor_name TEXT,
direction TEXT, summary TEXT, proposed_note TEXT, email_subject TEXT, email_date TEXT, status TEXT DEFAULT 'pending',
decided_by TEXT, decided_at TEXT, final_note TEXT, created_at TEXT);
CREATE TABLE email_proposal_matrix (proposal_id TEXT PRIMARY KEY, event_id TEXT, posted_at TEXT, closed_at TEXT, created_at TEXT);
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT);
CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, notes TEXT);
CREATE TABLE fundraising_state (id TEXT PRIMARY KEY, grid_json TEXT, views_json TEXT, version INT,
updated_by TEXT REFERENCES users(id), updated_at TEXT);
CREATE TABLE interaction_log (id TEXT PRIMARY KEY, ts TEXT, actor_type TEXT, actor_id TEXT, action TEXT, target_type TEXT, target_id TEXT, payload TEXT, source TEXT, created_at TEXT);
""")
conn.execute("INSERT INTO users (id,username) VALUES ('user-1','grant')")
conn.execute("INSERT INTO app_settings VALUES ('email_activity_since', ?, ?)", (json.dumps("2026-01-01T00:00:00"), "x"))
conn.execute("INSERT INTO email_accounts (id,email_address) VALUES ('a','grant@ten31.xyz')")
conn.execute("INSERT INTO fundraising_investors (id,investor_name,notes) VALUES ('inv1','Harbor & Vine','existing note')")
grid = {"columns": [], "rows": [{"id": "inv1", "investor_name": "Harbor & Vine", "notes": "existing note"}]}
conn.execute("INSERT INTO fundraising_state (id,grid_json,views_json,version) VALUES ('main',?,?,1)", (json.dumps(grid), "[]"))
conn.executemany("INSERT INTO emails (id,subject,body_text,snippet,from_name,from_email,sent_at,is_matched,match_status) VALUES (?,?,?,?,?,?,?,1,'matched')", [
("e1", "Fund III", "Here is the update", "the quarterly update is attached", "Grant", "grant@ten31.xyz", "2026-06-01T10:00:00"),
("e2", "Re: Fund III", "Thanks, a question", "thanks — one question on terms", "LP Contact", "lp@harborvine.example", "2026-06-02T10:00:00"),
])
conn.executemany("INSERT INTO email_investor_links (id,email_id,fundraising_investor_id,match_confidence) VALUES (?,?, 'inv1', 1.0)",
[("l1", "e1"), ("l2", "e2")])
conn.commit()
conn.close()
def main():
setup()
# role gate: bot passes the agent gate but is NOT an admin; member passes neither.
check(server.require_bot_or_admin({"role": "bot"}), "bot passes require_bot_or_admin")
check(server.require_bot_or_admin({"role": "admin"}), "admin passes require_bot_or_admin")
check(not server.require_bot_or_admin({"role": "member"}), "member does NOT pass require_bot_or_admin")
check(not server.require_admin({"role": "bot"}), "bot is NOT an admin (no user-mgmt/settings reach)")
check(server.propose_email_activity_notes().get("proposed") == 2, "drafts 2 proposals")
conn = server.get_db()
props = server.list_email_activity_proposals(conn, status="pending")
by_email = {p["email_id"]: p for p in props}
p_a, p_b = by_email["e1"], by_email["e2"]
# Both are pending + un-posted → both in to_post; card carries from/snippet/note context.
lists = server.list_bot_email_proposals(conn)
check(len(lists["to_post"]) == 2 and not lists["open"] and not lists["to_close"], "both proposals queued to_post")
card = next(it for it in lists["to_post"] if it["id"] == p_a["id"])
check(card.get("from_name") == "Grant" and "quarterly update" in (card.get("snippet") or ""), "card carries from_name + snippet")
check("" in (card.get("proposed_note") or ""), "card carries the drafted note")
# Post p_a to Matrix → it leaves to_post and becomes an open thread (event id recorded).
server.mark_proposal_matrix_posted(conn, p_a["id"], "evtA")
lists = server.list_bot_email_proposals(conn)
check(len(lists["to_post"]) == 1 and lists["to_post"][0]["id"] == p_b["id"], "posting p_a leaves only p_b to_post")
check(len(lists["open"]) == 1 and lists["open"][0]["id"] == p_a["id"] and lists["open"][0]["event_id"] == "evtA",
"posted p_a is an open thread carrying its event id")
# Decide p_a IN-THREAD on Matrix (approve + close in one transaction).
r = server.decide_email_activity_proposal(conn, p_a["id"], "approve", "user-1", source="matrix", close_matrix=True)
check(r.get("status") == "approved" and r.get("placed_in_grid") is True, "matrix approve appends to the grid")
lists = server.list_bot_email_proposals(conn)
check(not any(it["id"] == p_a["id"] for it in lists["open"] + lists["to_close"]),
"matrix-decided proposal is closed (not re-announced via to_close)")
src = conn.execute("SELECT source FROM interaction_log WHERE action='email.activity_approved'").fetchone()["source"]
check(src == "matrix", "matrix decision is audited source='matrix'")
# Web-decide path: post p_b, then dismiss it on the WEB (default source, no close) → the bot
# must see it in to_close so it can announce the web decision in-thread, then close.
server.mark_proposal_matrix_posted(conn, p_b["id"], "evtB")
server.decide_email_activity_proposal(conn, p_b["id"], "dismiss", "user-1") # web path: source crm_ui, no close
lists = server.list_bot_email_proposals(conn)
check(len(lists["to_close"]) == 1 and lists["to_close"][0]["id"] == p_b["id"] and lists["to_close"][0]["status"] == "dismissed",
"web-decided open thread surfaces in to_close")
src2 = conn.execute("SELECT source FROM interaction_log WHERE action='email.activity_dismissed'").fetchone()["source"]
check(src2 == "crm_ui", "web decision is audited source='crm_ui'")
server.mark_proposal_matrix_closed(conn, p_b["id"])
lists = server.list_bot_email_proposals(conn)
check(not lists["to_close"] and not lists["open"], "closing the thread clears the work-lists")
# Marking a non-existent proposal is a clean not_found, not a crash.
check(server.mark_proposal_matrix_posted(conn, "nope", "evtX").get("error") == "not_found", "mark posted on unknown id -> not_found")
conn.close()
if FAILS:
print(f"\nFAILED ({len(FAILS)})")
for f in FAILS:
print(" - " + f)
sys.exit(1)
print("\nALL PASS (email-proposal Matrix bridge)")
if __name__ == "__main__":
main()
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Tests for the grid → Pipeline link ("Adopt the Pipeline", v0.1.0:87).
Boots the REAL server against a temp DB and exercises the new endpoints end-to-end:
- POST /api/fundraising/pipeline/link creates exactly ONE opportunity, linked via
opportunities.fundraising_investor_id, reusing the grid's synced contact (no
POST /api/contacts side-door) and mapping the grid 'lead' -> owner;
- the link is idempotent: a re-link returns the existing opp and NEVER reseeds its
Pipeline-owned funnel fields (stage/probability) — the board owns those;
- GET /api/fundraising/state injects read-only pipeline / pipeline_stage row values
derived from the live opp;
- linking a contactless row, or an unknown row, is refused;
- POST .../unlink soft-deletes the opp (off the board, recoverable) while leaving the
grid investor row fully intact;
- deleting an investor from the grid archives its orphaned opp on the next save;
- the pipeline report + dashboard aggregates exclude archived (soft-deleted) opps.
Synthetic data only.
Run: cd backend && python3 test_grid_pipeline_link.py
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import server # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
class _Quiet(server.CRMHandler):
def log_message(self, *a):
pass
def _req(port, method, path, token=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
headers = {}
if token:
headers["Authorization"] = "Bearer " + token
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
data = None
if raw:
try:
data = json.loads(raw)
except ValueError:
pass
return resp.status, data
def _put_grid(port, token, rows):
return _req(port, "PUT", "/api/fundraising/state", token,
{"grid": {"columns": [], "rows": rows}, "views": []})
ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "notes": "", "lead": "Grant",
"contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]}
ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital LLC", "notes": "", "lead": "",
"contacts": [{"name": "Pat Roe", "email": "pat@beta.com", "title": ""}]}
ROW_EMPTY = {"id": "rowEmpty", "investor_name": "Empty LP", "notes": "", "contacts": []}
def _db():
return sqlite3.connect(os.environ["CRM_DB_PATH"])
def seed():
c = _db()
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
c.commit()
c.close()
def _opp_count_live(fr_investor_id=None):
c = _db()
if fr_investor_id:
n = c.execute("SELECT COUNT(*) FROM opportunities WHERE fundraising_investor_id = ? "
"AND deleted_at IS NULL", (fr_investor_id,)).fetchone()[0]
else:
n = c.execute("SELECT COUNT(*) FROM opportunities WHERE deleted_at IS NULL").fetchone()[0]
c.close()
return n
def main():
server.init_db()
seed()
token = server.create_token("u1", "grant", "admin")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA, ROW_EMPTY])
check(st == 200, f"seed grid via PUT /state (got {st})")
# ── link creates one linked opp with the seeds + resolved contact + mapped owner ──
print("\n[link: creates one linked opportunity with seeds]")
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
"source_row_id": "rowAcme", "fund_name": "Fund III",
"expected_amount": 250000, "probability": 40, "stage": "outreach",
})
opp = (d or {}).get("data") or {}
check(st == 201 and (d or {}).get("already_linked") is False, f"link -> 201 new (got {st}, {d})")
check(opp.get("stage") == "outreach" and opp.get("expected_amount") == 250000
and opp.get("probability") == 40 and opp.get("fund_name") == "Fund III",
f"seeds applied (got {{stage:{opp.get('stage')}, amt:{opp.get('expected_amount')}, "
f"prob:{opp.get('probability')}, fund:{opp.get('fund_name')}}})")
check(opp.get("first_name") == "Jane", f"reused synced contact Jane Doe (got {opp.get('first_name')})")
check(opp.get("owner_name") == "Grant", f"grid lead 'Grant' -> owner Grant (got {opp.get('owner_name')})")
fr_id = opp.get("fundraising_investor_id")
check(bool(fr_id), f"opportunity carries fundraising_investor_id (got {fr_id})")
check(_opp_count_live(fr_id) == 1, "exactly one live opp linked to the investor")
opp_id = opp.get("id")
# ── idempotent re-link: returns existing, board-owned stage NOT reseeded ──
print("\n[idempotent: re-link returns existing opp without reseeding funnel fields]")
st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "meeting"})
check(st == 200, f"advance stage on the board -> meeting (got {st})")
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
"source_row_id": "rowAcme", "stage": "lead", "expected_amount": 999, "probability": 5,
})
opp2 = (d or {}).get("data") or {}
check(st == 200 and (d or {}).get("already_linked") is True, f"re-link -> already_linked (got {st}, {d})")
check(opp2.get("stage") == "meeting" and opp2.get("expected_amount") == 250000,
f"funnel fields preserved, not reseeded (got stage={opp2.get('stage')}, amt={opp2.get('expected_amount')})")
check(_opp_count_live(fr_id) == 1, "still exactly one live opp (no duplicate)")
# ── read-injection: GET state shows pipeline flag + stage, derived live ──
print("\n[read-injection: GET /state exposes read-only pipeline + pipeline_stage]")
st, d = _req(port, "GET", "/api/fundraising/state", token)
rows = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}
check(rows.get("rowAcme", {}).get("pipeline") is True
and rows.get("rowAcme", {}).get("pipeline_stage") == "meeting",
f"rowAcme pipeline true @meeting (got {rows.get('rowAcme', {}).get('pipeline')}, "
f"{rows.get('rowAcme', {}).get('pipeline_stage')})")
check(rows.get("rowBeta", {}).get("pipeline") is False
and rows.get("rowBeta", {}).get("pipeline_stage") == "",
f"rowBeta not in pipeline (got {rows.get('rowBeta', {}).get('pipeline')})")
# ── round-trip: a save echoing the injected read-only values is lossless ──
print("\n[round-trip: PUT carrying injected pipeline values strips them, link intact]")
st, d = _req(port, "GET", "/api/fundraising/state", token)
echoed = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
st, _ = _put_grid(port, token, echoed) # as the frontend autosave would, rows still carry pipeline*
check(st == 200, f"echo-back save -> 200 (got {st})")
check(_opp_count_live(fr_id) == 1, "link survives the round-trip (no dup, not archived)")
c = _db()
blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0])
c.close()
stored_acme = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {})
check("pipeline" not in stored_acme and "pipeline_stage" not in stored_acme,
"computed keys are NOT persisted into the grid blob")
st, d = _req(port, "GET", "/api/fundraising/state", token)
rt = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}.get("rowAcme", {})
check(rt.get("pipeline") is True and rt.get("pipeline_stage") == "meeting",
f"pipeline values re-injected after round-trip (got {rt.get('pipeline')}, {rt.get('pipeline_stage')})")
# ── guards ──
print("\n[guard: a contactless row cannot be added to the pipeline]")
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {"source_row_id": "rowEmpty"})
check(st == 400, f"no contact -> 400 (got {st}, {d})")
check(_opp_count_live() == 1, "no stray opp created for the contactless row")
print("\n[guard: unknown grid row -> 404]")
st, _ = _req(port, "POST", "/api/fundraising/pipeline/link", token, {"source_row_id": "nope"})
check(st == 404, f"unknown row -> 404 (got {st})")
print("\n[guard: unauthenticated -> 401]")
st, _ = _req(port, "POST", "/api/fundraising/pipeline/link", None, {"source_row_id": "rowAcme"})
check(st == 401, f"no token -> 401 (got {st})")
# ── the opp loads on the board + counts in the dashboard while live ──
print("\n[board + dashboard count the live opp]")
st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
ids = [o["id"] for o in (d or {}).get("data", [])]
check(opp_id in ids, "linked opp appears on the board")
st, d = _req(port, "GET", "/api/reports/dashboard", token)
active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities")
check(active == 1, f"dashboard active_opportunities == 1 (got {active})")
# ── unlink soft-deletes the opp; the GRID ROW stays fully intact ──
print("\n[unlink: archives the opp, leaves the grid investor intact]")
st, d = _req(port, "POST", "/api/fundraising/pipeline/unlink", token, {"source_row_id": "rowAcme"})
check(st == 200 and (d or {}).get("data", {}).get("archived") == 1, f"unlink -> archived 1 (got {st}, {d})")
check(_opp_count_live(fr_id) == 0, "opp is no longer live (soft-deleted)")
c = _db()
gone = c.execute("SELECT deleted_at FROM opportunities WHERE id = ?", (opp_id,)).fetchone()[0]
inv_still = c.execute("SELECT investor_name FROM fundraising_investors WHERE source_row_id = 'rowAcme'").fetchone()
contact_still = c.execute("SELECT COUNT(*) FROM fundraising_contacts WHERE investor_id = ?", (fr_id,)).fetchone()[0]
c.close()
check(gone is not None, "opp row tombstoned (deleted_at set), not hard-deleted")
check(inv_still and inv_still[0] == "Acme Capital", "grid investor row untouched by unlink")
check(contact_still >= 1, "grid investor's contacts untouched by unlink")
st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
check(opp_id not in [o["id"] for o in (d or {}).get("data", [])], "archived opp left the board")
st, d = _req(port, "GET", "/api/fundraising/state", token)
rows = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}
check(rows.get("rowAcme", {}).get("pipeline") is False, "grid no longer flags rowAcme as in-pipeline")
# ── aggregates exclude the archived opp ──
print("\n[aggregates exclude archived opps]")
st, d = _req(port, "GET", "/api/reports/dashboard", token)
active = (d or {}).get("data", {}).get("metrics", {}).get("active_opportunities")
check(active == 0, f"dashboard active_opportunities back to 0 (got {active})")
st, d = _req(port, "GET", "/api/reports/pipeline", token)
by_stage = (d or {}).get("data", {}).get("by_stage", [])
total = sum(s.get("count", 0) for s in by_stage)
check(total == 0, f"pipeline report by_stage excludes archived (got total {total})")
# ── re-link after unlink: a fresh opp is created (the archived one stays archived) ──
print("\n[re-link after unlink: creates a new opp, flag reappears]")
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
"source_row_id": "rowAcme", "stage": "outreach", "expected_amount": 50000,
})
relinked = (d or {}).get("data") or {}
check(st == 201 and (d or {}).get("already_linked") is False and relinked.get("id") != opp_id,
f"re-link -> a NEW opp distinct from the archived one (got {st}, {relinked.get('id')} vs {opp_id})")
check(_opp_count_live(fr_id) == 1, "exactly one live opp again after re-link")
st, _ = _req(port, "POST", "/api/fundraising/pipeline/unlink", token, {"source_row_id": "rowAcme"})
check(st == 200, "reset: unlink the re-linked opp")
# ── orphan reconciler: deleting the investor from the grid archives its opp ──
print("\n[orphan: deleting the grid investor archives its linked opp on next save]")
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
"source_row_id": "rowBeta", "stage": "lead", "expected_amount": 100000,
})
beta = (d or {}).get("data") or {}
beta_opp_id, beta_fr = beta.get("id"), beta.get("fundraising_investor_id")
check(st == 201 and _opp_count_live(beta_fr) == 1, f"beta linked (got {st})")
# drop rowBeta from the grid (keep the others)
st, _ = _put_grid(port, token, [ROW_ACME, ROW_EMPTY])
check(st == 200, f"save grid without rowBeta (got {st})")
check(_opp_count_live(beta_fr) == 0, "beta's orphaned opp archived by the reconciler")
st, d = _req(port, "GET", "/api/opportunities?limit=1000", token)
check(beta_opp_id not in [o["id"] for o in (d or {}).get("data", [])], "orphaned opp left the board")
finally:
httpd.shutdown()
print("\n" + ("ALL PASS" if not FAILS else f"{len(FAILS)} FAILURE(S):"))
for f in FAILS:
print(" - " + f)
sys.exit(1 if FAILS else 0)
if __name__ == "__main__":
main()
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""Tests for the Matrix-intake CRM surface (v0.1.0 Matrix-intake M2).
The bot adds no parallel write path — it reuses /api/fundraising/log-communication and adds
one read-only lookup, GET /api/intake/match. This boots the REAL server against a temp DB and
asserts:
- match by normalized name and by contact email, returning the GRID ROW id;
- the new-vs-existing contract: a bot-style create (log-communication +
create_investor_if_missing) then matches by name — so an approved note lands on that same
investor instead of duplicating it;
- provenance: an intake-sourced communication is audited with source="matrix_intake";
- guards: missing q/email -> 400, unauthenticated -> 401.
Synthetic data only.
Run: cd backend && python3 test_intake_endpoints.py
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from http.server import ThreadingHTTPServer
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import server # noqa: E402
FAILS = []
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
class _Quiet(server.CRMHandler):
def log_message(self, *a):
pass
def _req(port, method, path, token=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
headers = {}
if token:
headers["Authorization"] = "Bearer " + token
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
data = None
if raw:
try:
data = json.loads(raw)
except ValueError:
pass
return resp.status, data
GRID = {
"columns": [],
"rows": [
{"id": "rowAcme", "investor_name": "Acme Capital", "notes": "",
"contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]},
{"id": "rowCharlie", "investor_name": "Charlie Brown", "notes": "",
"contacts": [{"name": "Charlie Brown", "email": "cb@brown.fund", "title": ""}]},
{"id": "rowBeta", "investor_name": "Beta Capital LLC", "notes": "",
"contacts": [{"name": "Pat Roe", "email": "pat@beta.com", "title": ""}]},
],
}
def seed():
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
# init_db doesn't create the 'main' state row (it's created lazily on first write), so
# upsert rather than UPDATE — a plain UPDATE would silently match zero rows.
c.execute("INSERT INTO fundraising_state (id, grid_json, views_json, version) "
"VALUES ('main', ?, '[]', 1) "
"ON CONFLICT(id) DO UPDATE SET grid_json = excluded.grid_json", (json.dumps(GRID),))
c.commit()
c.close()
def main():
server.init_db()
seed()
token = server.create_token("u1", "grant", "admin")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
print("\n[match: existing investor by name returns the grid row id]")
st, d = _req(port, "GET", "/api/intake/match?q=Acme%20Capital", token)
m = (d or {}).get("data", {}).get("match")
check(st == 200 and m and m["id"] == "rowAcme" and m["matched_on"] == "name",
f"name match -> rowAcme (got {st}, {m})")
print("\n[match: case-insensitive name]")
st, d = _req(port, "GET", "/api/intake/match?q=acme%20capital", token)
m = (d or {}).get("data", {}).get("match")
check(m and m["id"] == "rowAcme", f"normalized name match (got {m})")
print("\n[match: by contact email]")
st, d = _req(port, "GET", "/api/intake/match?email=jane@acme.com", token)
m = (d or {}).get("data", {}).get("match")
check(m and m["id"] == "rowAcme" and m["matched_on"] == "email",
f"email match -> rowAcme (got {m})")
print("\n[match: unknown -> null]")
st, d = _req(port, "GET", "/api/intake/match?q=Nobody%20LP", token)
check(st == 200 and (d or {}).get("data", {}).get("match") is None,
f"no match -> null (got {st}, {d})")
print("\n[fuzzy: exact match returns no candidates (bot auto-attaches)]")
st, d = _req(port, "GET", "/api/intake/match?q=Acme%20Capital", token)
data = (d or {}).get("data", {})
check(st == 200 and data.get("match") and data.get("candidates") == [],
f"exact match -> match set, candidates empty (got {data})")
print("\n[fuzzy: near-spelling surfaces a candidate (Charles Brown ~ Charlie Brown)]")
st, d = _req(port, "GET", "/api/intake/match?q=Charles%20Brown", token)
data = (d or {}).get("data", {})
cids = [c["id"] for c in data.get("candidates", [])]
check(data.get("match") is None and "rowCharlie" in cids,
f"near-spelling -> candidate rowCharlie, no exact (got {data})")
print("\n[fuzzy: legal-suffix difference surfaces a candidate (Beta Capital ~ Beta Capital LLC)]")
st, d = _req(port, "GET", "/api/intake/match?q=Beta%20Capital", token)
data = (d or {}).get("data", {})
cids = [c["id"] for c in data.get("candidates", [])]
check(data.get("match") is None and "rowBeta" in cids,
f"legal-suffix -> candidate rowBeta, no exact (got {data})")
print("\n[fuzzy: legal-suffix-only difference ranks as a top candidate (Acme Capital LLC ~ Acme Capital)]")
st, d = _req(port, "GET", "/api/intake/match?q=Acme%20Capital%20LLC", token)
data = (d or {}).get("data", {})
top = (data.get("candidates") or [None])[0]
check(data.get("match") is None and top and top["id"] == "rowAcme" and top["score"] == 1.0,
f"legal-suffix-only -> rowAcme top candidate @1.0, no exact (got {data})")
print("\n[fuzzy: one-character email typo surfaces a candidate by email]")
st, d = _req(port, "GET", "/api/intake/match?email=jhane@acme.com", token)
data = (d or {}).get("data", {})
cands = data.get("candidates", [])
hit = next((c for c in cands if c["id"] == "rowAcme"), None)
check(data.get("match") is None and hit and hit["matched_on"] == "email",
f"email typo -> candidate rowAcme matched_on email (got {data})")
print("\n[fuzzy: two-character email typo (distance 2) still surfaces]")
st, d = _req(port, "GET", "/api/intake/match?email=jane@acne.con", token) # acme->acne, com->con
data = (d or {}).get("data", {})
hit = next((c for c in data.get("candidates", []) if c["id"] == "rowAcme"), None)
check(data.get("match") is None and hit and hit["matched_on"] == "email" and hit["score"] == 0.8,
f"dist-2 email -> rowAcme @0.8 (got {data})")
print("\n[fuzzy: a row matching on BOTH name and email appears once (deduped)]")
st, d = _req(port, "GET", "/api/intake/match?q=Acme%20Capitol&email=jhane@acme.com", token)
data = (d or {}).get("data", {})
acme_hits = [c for c in data.get("candidates", []) if c["id"] == "rowAcme"]
check(data.get("match") is None and len(acme_hits) == 1,
f"name+email both match rowAcme -> single deduped entry (got {data})")
print("\n[fuzzy: nothing close -> empty candidates]")
st, d = _req(port, "GET", "/api/intake/match?q=Zphq%20Nobody%20LP", token)
data = (d or {}).get("data", {})
check(st == 200 and data.get("match") is None and data.get("candidates") == [],
f"unrelated query -> no match, no candidates (got {data})")
print("\n[match: missing q and email -> 400]")
st, _ = _req(port, "GET", "/api/intake/match", token)
check(st == 400, f"no params -> 400 (got {st})")
print("\n[match: unauthenticated -> 401]")
st, _ = _req(port, "GET", "/api/intake/match?q=Acme", None)
check(st == 401, f"no token -> 401 (got {st})")
print("\n[bot create: log-communication + create_investor_if_missing, source tagged]")
st, d = _req(port, "POST", "/api/fundraising/log-communication", token, {
"investor_name": "Beacon Ventures",
"contact": {"name": "Sam Lee", "email": "sam@beacon.vc", "title": "Partner"},
"create_investor_if_missing": True,
"type": "note", "subject": "Intake (Matrix)", "body": "met at the Austin conf",
"source": "matrix_intake",
})
check(st in (200, 201), f"create new investor -> 201 (got {st})")
print("\n[new-vs-existing contract: the just-created investor now matches by name]")
st, d = _req(port, "GET", "/api/intake/match?q=Beacon%20Ventures", token)
m = (d or {}).get("data", {}).get("match")
check(m and m.get("investor_name") == "Beacon Ventures",
f"created investor is matchable (no duplicate on next note) (got {m})")
print("\n[provenance: the intake communication is audited as source=matrix_intake]")
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
rows = c.execute("SELECT changes FROM audit_log WHERE entity_type='communication' AND action='create'").fetchall()
c.close()
sources = [json.loads(r[0]).get("source") for r in rows if r[0]]
check("matrix_intake" in sources, f"audit carries source=matrix_intake (got {sources})")
finally:
httpd.shutdown()
print()
if FAILS:
print(f"FAILED ({len(FAILS)}):")
for f in FAILS:
print(f" - {f}")
sys.exit(1)
print("ALL PASS (matrix-intake endpoints)")
if __name__ == "__main__":
main()
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Tests for reminders / follow-ups (W1).
Boots the REAL server against a temp DB and exercises the new endpoints end-to-end:
- POST /api/reminders creates an open reminder tied to a grid investor (denormalized
investor_name resolved from the grid), or a standalone task (no investor_id);
- GET /api/reminders lists + filters by status (active/open/done/...), overdue, investor_id,
assignee=me; every read is soft-delete filtered;
- PATCH completes (stamps completed_at) / snoozes / edits a reminder; status is validated;
- DELETE soft-deletes (gone from every list, never hard-deleted);
- GET /api/fundraising/state injects a read-only reminder_status (overdue/due_soon/open/'')
derived live from open reminders, and strips it on save (never persisted to the blob);
- deleting an investor from the grid cancels its orphaned reminders (reconcile twin),
while a standalone reminder is untouched;
- the usual guards (missing title -> 400, bad status -> 400, unknown investor -> 404,
unauthenticated -> 401).
Synthetic data only.
Run: cd backend && python3 test_reminders.py
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from datetime import datetime, timedelta
from http.server import ThreadingHTTPServer
_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import server # noqa: E402
FAILS = []
TODAY = datetime.utcnow().date()
TOMORROW = (TODAY + timedelta(days=1)).isoformat()
YESTERDAY = (TODAY - timedelta(days=1)).isoformat()
FAR = (TODAY + timedelta(days=30)).isoformat()
def check(cond, msg):
print((" PASS " if cond else " FAIL ") + msg)
if not cond:
FAILS.append(msg)
class _Quiet(server.CRMHandler):
def log_message(self, *a):
pass
def _req(port, method, path, token=None, body=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
headers = {}
if token:
headers["Authorization"] = "Bearer " + token
payload = None
if body is not None:
payload = json.dumps(body)
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
data = None
if raw:
try:
data = json.loads(raw)
except ValueError:
pass
return resp.status, data
def _put_grid(port, token, rows):
return _req(port, "PUT", "/api/fundraising/state", token,
{"grid": {"columns": [], "rows": rows}, "views": []})
ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "notes": "", "lead": "Grant",
"contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]}
ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital", "notes": "",
"contacts": [{"name": "Pat Roe", "email": "pat@beta.com"}]}
ROW_GAMMA = {"id": "rowGamma", "investor_name": "Gamma Partners", "notes": "",
"contacts": [{"name": "Sam Lee", "email": "sam@gamma.com"}]}
def _db():
return sqlite3.connect(os.environ["CRM_DB_PATH"])
def seed():
c = _db()
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
c.commit()
c.close()
def _investor_id(source_row_id):
c = _db()
r = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id = ?", (source_row_id,)).fetchone()
c.close()
return r[0] if r else None
def _grid_reminder_status(port, token):
st, d = _req(port, "GET", "/api/fundraising/state", token)
rows = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
return {r["id"]: r.get("reminder_status") for r in rows}
def main():
server.init_db()
seed()
token = server.create_token("u1", "grant", "admin")
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA, ROW_GAMMA])
check(st == 200, f"seed grid via PUT /state (got {st})")
acme_id = _investor_id("rowAcme")
beta_id = _investor_id("rowBeta")
gamma_id = _investor_id("rowGamma")
check(bool(acme_id and beta_id and gamma_id), "investor ids resolved from the grid")
# ── create: investor-linked + denormalized name resolved from the grid ──
print("\n[create: investor-linked reminder resolves the denormalized name]")
st, d = _req(port, "POST", "/api/reminders", token,
{"investor_id": acme_id, "title": "Send Fund III deck", "due_date": TOMORROW})
rem = (d or {}).get("data") or {}
acme_rem_id = rem.get("id")
check(st == 201 and rem.get("status") == "open", f"create -> 201 open (got {st}, {d})")
check(rem.get("investor_name") == "Acme Capital", f"name denormalized from grid (got {rem.get('investor_name')})")
# overdue reminder on Beta; far-future + standalone for filter coverage
st, d = _req(port, "POST", "/api/reminders", token,
{"investor_id": beta_id, "title": "Call Pat", "due_date": YESTERDAY})
beta_rem_id = (d or {}).get("data", {}).get("id")
check(st == 201, f"create overdue beta reminder (got {st})")
st, d = _req(port, "POST", "/api/reminders", token,
{"investor_id": gamma_id, "title": "Quarterly touch", "due_date": FAR})
gamma_rem_id = (d or {}).get("data", {}).get("id")
st, d = _req(port, "POST", "/api/reminders", token, {"title": "Team: refresh pipeline view"})
standalone_id = (d or {}).get("data", {}).get("id")
check(st == 201 and (d or {}).get("data", {}).get("investor_id") in (None, ""),
f"standalone reminder (no investor) created (got {st})")
# ── list + filters ──
print("\n[list + filters]")
st, d = _req(port, "GET", "/api/reminders", token)
items = (d or {}).get("data", [])
check(st == 200 and len(items) == 4, f"list returns all 4 open (got {st}, {len(items)})")
check(all("last_activity_at" in it for it in items), "each row carries last_activity_at")
# dated reminders sort before undated, soonest first -> YESTERDAY (beta) leads
check(items and items[0].get("id") == beta_rem_id, f"overdue sorts first (got {items[0].get('id') if items else None})")
st, d = _req(port, "GET", "/api/reminders?overdue=1", token)
ids = [it["id"] for it in (d or {}).get("data", [])]
check(ids == [beta_rem_id], f"overdue=1 -> only the past-due one (got {ids})")
# overdue owns the status constraint: a conflicting status= must not silently zero out
st, d = _req(port, "GET", "/api/reminders?overdue=1&status=done", token)
ids = [it["id"] for it in (d or {}).get("data", [])]
check(ids == [beta_rem_id], f"overdue wins over a conflicting status= (got {ids})")
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
ids = [it["id"] for it in (d or {}).get("data", [])]
check(ids == [acme_rem_id], f"investor_id filter (got {ids})")
st, d = _req(port, "GET", "/api/reminders?assignee=me", token)
check(len(d.get("data", [])) == 0, "assignee=me -> none (reminders created unassigned)")
# ── grid injection: reminder_status derived live, never persisted ──
print("\n[grid injection: read-only reminder_status]")
s = _grid_reminder_status(port, token)
check(s.get("rowAcme") == "due_soon", f"due-tomorrow -> due_soon (got {s.get('rowAcme')})")
check(s.get("rowBeta") == "overdue", f"past-due -> overdue (got {s.get('rowBeta')})")
check(s.get("rowGamma") == "open", f"far-future -> open (got {s.get('rowGamma')})")
# echo the injected value back on save; it must NOT persist into the blob
st, d = _req(port, "GET", "/api/fundraising/state", token)
echoed = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
st, _ = _put_grid(port, token, echoed)
c = _db()
blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0])
c.close()
acme_stored = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {})
check("reminder_status" not in acme_stored, "reminder_status not persisted into the grid blob")
# ── complete / reopen stamps completed_at ──
print("\n[complete + reopen]")
st, d = _req(port, "PATCH", f"/api/reminders/{acme_rem_id}", token, {"status": "done"})
check(st == 200 and (d or {}).get("data", {}).get("status") == "done"
and (d or {}).get("data", {}).get("completed_at"), f"done stamps completed_at (got {d})")
st, d = _req(port, "GET", "/api/reminders?status=open", token)
check(acme_rem_id not in [it["id"] for it in (d or {}).get("data", [])], "done reminder drops from status=open")
st, d = _req(port, "GET", "/api/reminders?status=done", token)
check(acme_rem_id in [it["id"] for it in (d or {}).get("data", [])], "done reminder shows under status=done")
check(_grid_reminder_status(port, token).get("rowAcme") in (None, ""), "completed reminder clears the grid chip")
st, d = _req(port, "PATCH", f"/api/reminders/{acme_rem_id}", token, {"status": "open"})
check((d or {}).get("data", {}).get("completed_at") in (None, ""), "reopen clears completed_at")
# ── snooze: out of 'open', still in 'active' ──
print("\n[snooze]")
st, d = _req(port, "PATCH", f"/api/reminders/{beta_rem_id}", token,
{"status": "snoozed", "snoozed_until": FAR})
check(st == 200 and (d or {}).get("data", {}).get("status") == "snoozed", f"snooze (got {st})")
st, d = _req(port, "GET", "/api/reminders?status=open", token)
check(beta_rem_id not in [it["id"] for it in (d or {}).get("data", [])], "snoozed drops from status=open")
st, d = _req(port, "GET", "/api/reminders?status=active", token)
check(beta_rem_id in [it["id"] for it in (d or {}).get("data", [])], "snoozed stays in status=active")
# ── edit title + due date ──
print("\n[edit]")
st, d = _req(port, "PATCH", f"/api/reminders/{gamma_rem_id}", token,
{"title": "Quarterly touch — Q3", "due_date": TOMORROW})
check((d or {}).get("data", {}).get("title") == "Quarterly touch — Q3"
and (d or {}).get("data", {}).get("due_date") == TOMORROW, f"title+due edited (got {d})")
# ── soft-delete: gone from every list, tombstoned not hard-deleted ──
print("\n[soft-delete]")
st, d = _req(port, "DELETE", f"/api/reminders/{standalone_id}", token)
check(st == 200 and (d or {}).get("data", {}).get("deleted") is True, f"delete -> 200 (got {st})")
st, d = _req(port, "GET", "/api/reminders", token)
check(standalone_id not in [it["id"] for it in (d or {}).get("data", [])], "deleted reminder hidden from list")
c = _db()
gone = c.execute("SELECT deleted_at FROM reminders WHERE id = ?", (standalone_id,)).fetchone()[0]
c.close()
check(gone is not None, "deleted reminder tombstoned (deleted_at set), not hard-deleted")
# ── orphan reconcile: drop the investor from the grid -> its reminders cancelled ──
print("\n[orphan reconcile: deleting the grid investor cancels its reminders]")
# create a fresh standalone task to confirm it is NOT cancelled by the reconciler
st, d = _req(port, "POST", "/api/reminders", token, {"title": "Standalone keeps living"})
keep_id = (d or {}).get("data", {}).get("id")
st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA]) # drop rowGamma
c = _db()
gamma_status = c.execute("SELECT status FROM reminders WHERE id = ?", (gamma_rem_id,)).fetchone()[0]
keep_status = c.execute("SELECT status FROM reminders WHERE id = ?", (keep_id,)).fetchone()[0]
gamma_name = c.execute("SELECT investor_name FROM reminders WHERE id = ?", (gamma_rem_id,)).fetchone()[0]
c.close()
check(gamma_status == "cancelled", f"orphaned investor's reminder cancelled (got {gamma_status})")
check(gamma_name == "Gamma Partners", "cancelled reminder keeps denormalized investor_name for history")
check(keep_status == "open", f"standalone reminder untouched by reconcile (got {keep_status})")
# ── recency rollup: a tombstoned email sighting must not inflate last_activity_at ──
print("\n[recency: soft-deleted email sighting excluded from last_activity_at]")
c = _db()
c.execute("INSERT INTO emails (id, rfc_message_id, from_email, sent_at, is_matched, match_status) "
"VALUES ('em1','<em1@x>','lp@acme.com','2026-06-10T00:00:00Z',1,'matched')")
c.execute("INSERT INTO email_investor_links (id, email_id, fundraising_investor_id, match_kind, match_confidence, matched_address) "
"VALUES ('eil1','em1',?, 'exact_email',1.0,'lp@acme.com')", (acme_id,))
c.execute("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, gmail_thread_id, deleted_at) "
"VALUES ('eam1','em1','acct1','g1','t1','2026-06-11T00:00:00Z')") # tombstoned sighting only
c.commit(); c.close()
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
items = (d or {}).get("data", [])
la = items[0].get("last_activity_at") if items else "MISSING"
check(bool(items) and la is None, f"tombstoned-only email -> no last_activity (got {la})")
c = _db()
c.execute("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, gmail_thread_id, deleted_at) "
"VALUES ('eam2','em1','acct2','g2','t2',NULL)")
c.commit(); c.close()
st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
items = (d or {}).get("data", [])
la = items[0].get("last_activity_at") if items else "MISSING"
check(bool(items) and la == '2026-06-10T00:00:00Z', f"live sighting -> last_activity set (got {la})")
# ── guards ──
print("\n[guards]")
st, _ = _req(port, "POST", "/api/reminders", token, {"investor_id": acme_id})
check(st == 400, f"missing title -> 400 (got {st})")
st, _ = _req(port, "POST", "/api/reminders", token, {"title": "x", "investor_id": "nope"})
check(st == 404, f"unknown investor_id -> 404 (got {st})")
st, _ = _req(port, "PATCH", f"/api/reminders/{gamma_rem_id}", token, {"status": "bogus"})
check(st == 400, f"invalid status -> 400 (got {st})")
st, _ = _req(port, "DELETE", "/api/reminders/doesnotexist", token)
check(st == 404, f"delete unknown -> 404 (got {st})")
st, _ = _req(port, "GET", "/api/reminders", None)
check(st == 401, f"unauthenticated list -> 401 (got {st})")
finally:
httpd.shutdown()
print("\n" + ("ALL PASS" if not FAILS else f"{len(FAILS)} FAILURE(S):"))
for f in FAILS:
print(" - " + f)
sys.exit(1 if FAILS else 0)
if __name__ == "__main__":
main()
+2 -10
View File
@@ -11,7 +11,7 @@ payload. The fix added `deleted_at IS NULL` to every get-by-id + nested sub-sele
This boots the REAL server, hand-builds active + soft-deleted rows across the five This boots the REAL server, hand-builds active + soft-deleted rows across the five
soft-deletable tables, and drives the live HTTP read paths with a real token. It soft-deletable tables, and drives the live HTTP read paths with a real token. It
asserts: get-by-id 404s a soft-deleted contact/org, and nested sub-selects asserts: get-by-id 404s a soft-deleted contact/org, and nested sub-selects
(org->contacts/opportunities, contact->communications/opportunities/lp_profile) (org->contacts/opportunities, contact->communications/opportunities)
omit soft-deleted children while keeping the live ones. Synthetic only (guardrail #9). omit soft-deleted children while keeping the live ones. Synthetic only (guardrail #9).
Run: cd backend && python3 test_soft_delete_reads.py Run: cd backend && python3 test_soft_delete_reads.py
@@ -70,7 +70,7 @@ def seed():
# organizations: one live, one soft-deleted # organizations: one live, one soft-deleted
c.execute("INSERT INTO organizations (id,name) VALUES ('orgA','Harbor & Vine')") c.execute("INSERT INTO organizations (id,name) VALUES ('orgA','Harbor & Vine')")
c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgX','Deleted Org',?)", (DEL,)) c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgX','Deleted Org',?)", (DEL,))
# contacts under orgA: one live (with children), one soft-deleted, one live w/ deleted lp # contacts under orgA: one live (with children), one soft-deleted, one extra live (for org aggregates)
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cLive','Ada','Live','orgA')") c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cLive','Ada','Live','orgA')")
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id,deleted_at) VALUES ('cDead','Boris','Gone','orgA',?)", (DEL,)) c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id,deleted_at) VALUES ('cDead','Boris','Gone','orgA',?)", (DEL,))
c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cLp','Cora','Lp','orgA')") c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cLp','Cora','Lp','orgA')")
@@ -83,9 +83,6 @@ def seed():
# communications on cLive # communications on cLive
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject) VALUES ('cmLive','cLive','2026-05-01','u1','Live note')") c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject) VALUES ('cmLive','cLive','2026-05-01','u1','Live note')")
c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject,deleted_at) VALUES ('cmDead','cLive','2026-05-02','u1','Dead note',?)", (DEL,)) c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject,deleted_at) VALUES ('cmDead','cLive','2026-05-02','u1','Dead note',?)", (DEL,))
# lp_profiles: live one on cLive, soft-deleted one on cLp
c.execute("INSERT INTO lp_profiles (id,contact_id,fund_name) VALUES ('lpLive','cLive','Fund III')")
c.execute("INSERT INTO lp_profiles (id,contact_id,fund_name,deleted_at) VALUES ('lpDead','cLp','Fund III',?)", (DEL,))
c.commit() c.commit()
c.close() c.close()
@@ -115,11 +112,6 @@ def main():
opp_ids = {x["id"] for x in d.get("opportunities", [])} opp_ids = {x["id"] for x in d.get("opportunities", [])}
check("cmLive" in comm_ids and "cmDead" not in comm_ids, f"communications: live only (got {comm_ids})") check("cmLive" in comm_ids and "cmDead" not in comm_ids, f"communications: live only (got {comm_ids})")
check("opLive" in opp_ids and "opDead" not in opp_ids, f"opportunities: live only (got {opp_ids})") check("opLive" in opp_ids and "opDead" not in opp_ids, f"opportunities: live only (got {opp_ids})")
check(bool(d.get("lp_profile")) and d["lp_profile"].get("id") == "lpLive", "live lp_profile present on contact")
# soft-deleted lp_profile must read back as None (nested single-row sub-select)
_, lpc = _get(port, "/api/contacts/cLp", token)
check((lpc or {}).get("data", {}).get("lp_profile") is None, "soft-deleted lp_profile reads back as None")
# ── organization detail nested sub-selects exclude soft-deleted children ── # ── organization detail nested sub-selects exclude soft-deleted children ──
print("\n[organization detail nested sub-selects]") print("\n[organization detail nested sub-selects]")
+264
View File
@@ -0,0 +1,264 @@
# Design brief — Ten31 CRM mobile-first redesign
*The input packet for a Claude Design (or equivalent) round-trip. Goal: make the phone a
first-class, **preferred** surface for the Ten31 CRM without losing the existing look. This
is a **layout / information-architecture / interaction** redesign — the visual language is
captured in `design/DESIGN.md` + `design/tokens.tokens.json` and is **preserved**. Posture:
**preserve-but-refine** — keep the brand DNA; small mobile-warranted tweaks (type scale,
density, touch sizing) are welcome, a visual reskin is not.*
---
## 0. The one instruction that matters most
**Preserve the visual language; redesign only layout, navigation, and touch interaction.**
The current app is already a coherent, deliberate dark venture-CRM look (see `DESIGN.md`).
Do **not** reinvent the palette, typography, or component styling. Where mobile genuinely
warrants it, you may refine — bump body type from 13px toward 1516px, loosen touch density,
add bottom-sheet patterns — but the colors, the IBM Plex faces, the bordered-panel +
tinted-badge idiom, and the single `#3b82c4` accent stay.
## 1. Goal
The Ten31 CRM is the fund's system of record (~150 LPs, 250+ prospects, the capital-raise
pipeline), used by a ~5-person team. Today it's desktop-first; the team increasingly works
from phones — before/after investor meetings, on the move. Make **mobile the primary,
preferred surface**: every common on-the-go task is thumb-reachable and fast, and desktop
becomes the wide-screen enhancement rather than the baseline.
**Mobile is a focused subset, not the whole app** (decided 2026-06-18). Mobile carries only
the on-the-go core; everything else stays desktop-only.
- **Mobile surfaces — the only four:** **Fundraising Grid** (with fast switching between its
saved *views*), **Pipeline**, **Reminders**, **Contacts**. Every other screen — Dashboard,
Thesis, Thesis Workshop, Outreach, Communications, Email Capture, System Status, Feedback,
Instructions, Settings — is **desktop-only and simply absent** from the mobile UI (keep only
a minimal account/logout control).
- **Mobile editing — core records + quick capture (expanded 2026-06-18):** read everything;
editable on mobile is **investor name**, **contacts (name + email)**, **notes / communication
/ outreach log** (logging activity, not composing/sending — see §3a Backend reality),
**pipeline stage**, and **reminders***and* **creating a new investor**
(name + one or more contacts; no type field — Existing-Investor is auto-derived, pipeline stage
optional). Still **desktop-only**: commitments/amounts, the
full 20+ column set, column structure, bulk ops, and CSV. So mobile is "create and manage the
core investor record + log activity," not the full spreadsheet.
- **Create/edit investors go through the Grid — the canonical write path — never Contacts.**
Per the app's model, the `fundraising_*` grid is the system of record (investor row → contact
"pills" → commitments) and the **Contacts tab is a read-only directory auto-populated from
it**. So the **"+ add investor"** entry point and all name/contact/email edits live on the
**Grid** (its card list and detail); **Contacts stays read-only** on mobile — do not put an
add/edit affordance there.
- **Guard against duplicate investors on create.** Adding an investor from a phone is a
dupe-generation risk; the app already has entity resolution/merge. The mobile "+ add
investor" flow should **check for an existing match first** (search-as-you-type on name)
before creating a new row.
- **The Grid is card/detail on a phone, never a spreadsheet.** A row is one investor; mobile
shows an investor card list → full-screen detail with the editable set above, plus a create flow.
## 2. Layout (global, mobile-first)
- **Base = mobile; enhance up.** Author layout for a 375px column first, then add `min-width`
breakpoints for tablet/desktop. (Implementation note for later: the app's ~1300 inline
`style={{}}` objects can't respond to media queries — responsive layout must live in the
CSS `<style>` block / utility classes. The design tool doesn't need to solve this, but the
brief should assume layout, not inline style, carries responsiveness.)
- **Navigation → a 4-tab bottom bar; everything else is desktop-only.** Replace the 250px
sidebar with a **bottom tab bar of exactly four**: **Grid · Pipeline · Reminders ·
Contacts**. The other ten destinations are **not** present on mobile — there is no "More"
feature menu. Keep only a minimal **account control** (e.g. a top-bar avatar/menu) for
profile + logout. The bar respects `env(safe-area-inset-bottom)`.
- **Grid views are a first-class mobile control (high priority — second only to the tabs).**
The Grid's saved *views* (Main, Follow-up, All Investors, … and a growing set of
filter-based cuts) are how the team reads the investor list different ways. Surface a **view
picker at the top of the Grid screen** — a tappable current-view header that opens a
**bottom-sheet list of views**. Use a sheet/dropdown, not a fixed segmented control, because
the set of views grows over time. Switching a view re-filters the card list in place.
- **Overlays → bottom sheets.** Today's centered modal (500px) and right slide-over (400px,
which overflows a phone) become **drag-to-dismiss bottom sheets** or full-screen detail
views on mobile.
- **Touch + safe areas.** 44px minimum touch targets; sticky bottom nav respects
`env(safe-area-inset-bottom)`; content gets bottom padding so the nav never overlaps it.
## 3. Per-screen briefs
Take the design tool through these **one screen at a time** (mobile re-layout is screen-by-
screen, not a global token swap). Ordered by value and difficulty.
### 3a. Fundraising Grid — the crux (do first)
The core feature and system of record: a 20+ column, ~3,000px-wide editable table. A row =
**one investor** (contact "pills" + per-fund commitments). On a phone it is a **card list**,
never a wide table.
- **View switching, up top (high priority):** a tappable current-view name → **bottom-sheet
view picker** (Main / Follow-up / All Investors / … a growing set), plus search. Switching
re-filters the card list in place.
- **Card:** at-a-glance — **name · committed amount · pipeline stage · last contact**, plus two
derived indicators below. Tap → **full-screen investor detail** (today's slide-over, promoted).
*(Card model is locked — see ROADMAP "Pipeline stages + investor flags/labels — LOCKED SPEC.")*
- **Existing-Investor indicator** (auto-derived from any committed $): a quiet **star by the name
or a thin left accent edge** — *not* a per-card banner. Existing LPs are special; unmistakable
but restrained, in the blue accent.
- **Priority** is the **only top-right corner badge** — a star/pill when flagged, empty otherwise.
Graveyard is not a corner badge (those rows filter out / render muted). Priority + Graveyard are
the only two disposition flags; there is **no investor "type"** — drop any INVESTOR/PROSPECT chip.
- **Pipeline stage** chip shows **only when the row is in the pipeline** (`Lead → Engaged →
Diligence → Commitment`); most rows have none.
- **Last contact** carries staleness: grey when fresh → **amber → red** by age, appending "stale"
past one global threshold (e.g. "35d stale"). Derived from one server value, so grid + mobile
color-code identically.
- **Detail + edit:** the full field set is grouped into sections and **read-only**, *except*
the editable set: **investor name**, **contacts (name + email — the contact "pills")**,
**notes / communication / outreach** (a text area or "log a note" entry), **pipeline stage**
(a picker / segmented control), and **set/update a reminder** (date + note). Each edit happens
in a **bottom sheet**, one field at a time — no spreadsheet grid. Commitments/amounts and the
rest of the columns stay read-only on mobile.
- **Add investor (`+` on the Grid):** a create flow capturing **investor name + one or more
contacts (name, email)** — the minimum to start a record (no "type" to choose — Existing-Investor
is auto-derived from committed $; pipeline stage is optional, set only if adding to the pipeline);
the rest is filled later on desktop. **Search-as-you-type on name first** and offer existing matches before
creating, so a phone-added investor doesn't duplicate one already in the grid.
- The full multi-column spreadsheet (commitments/amounts, column reorder, bulk/CSV) stays
**desktop-only**.
> **Backend reality (read before designing the edit interactions — the write layer is not
> what "edit a field in a sheet" implies):**
> - **There is no field-level write. The grid is one JSON blob saved wholesale.**
> `PUT /api/fundraising/state` takes the *entire* grid and rejects with **409** if the global
> `version` moved under you (5 people edit live). So a naive "edit one field" = load the whole
> grid → mutate one row → PUT it all back → race everyone else. **Mobile single-investor edits
> (name, pills, add-investor, log-note) should instead go through the targeted, server-side,
> one-row path** `POST /api/fundraising/log-communication` (it finds/creates a single row,
> appends a note, and can create a new investor + first contact in one call via
> `create_investor_if_missing`, with no whole-grid version race) — or a new narrow per-row
> PATCH. Do **not** model these as whole-grid saves. The Matrix bot already uses this path.
> - **"Pipeline stage" is not a grid field** — it lives on the separate `opportunities` table and
> editing it is a **two-call flow with a precondition**: the row must first be *linked* to a
> pipeline opp (`POST /api/fundraising/pipeline/link`, which **requires ≥1 contact on the row**),
> *then* `PATCH /api/opportunities/{id}/stage`. The grid only *displays* stage read-only. So the
> detail-sheet "change stage" control needs a "not in pipeline yet → add to pipeline" state, and
> it shares the *same* opportunities endpoint as the Pipeline tab (3c) — consistent with that, not
> with the grid blob.
> - **Removing a contact pill has no tombstone/undo** — the `fundraising_*` tables are rebuilt on
> every save; the JSON blob is canonical. Don't promise soft-delete/undo semantics for pill
> removal (unlike comms/reminders, which *are* soft-deleted).
> - **Dedup typeahead is client-side** — filter the already-loaded rows; there is no investor-search
> endpoint, and the app's `entity_merge` is an admin-only *after-the-fact* reconciliation, not a
> create-time guard.
> - **"Notes / communication" = the `log-communication` path above (immediate write).** **Outreach
> *composition*** is a different, **gated** feature (agent drafts → human edits → human sends; the
> Outreach screen is desktop-only) — only the investor's outreach *log/notes* belong on mobile.
> - All of these are **plain-authenticated immediate writes** — a human **member** can do them on a
> phone; there is no draft→approve gate on a *human's own* edit (that gate is for *agent*-originated
> actions). "Agents draft, humans send" constrains the bot, not this UI.
### 3b. Contacts — lowest-risk transform (good pattern validator)
A **read-only** per-person directory (auto-populated from the Grid — no create/edit here),
today a table + tabs (All / Investors / Prospects) + a detail slide-over.
- **Mobile pattern:** a **list of contact rows** (initial/avatar · name · organization · type
badge · last contact) with the tabs as a top segmented control and search pinned → tap →
**full-screen read-only detail** (contact info, linked investor, communication history).
- Pure browse→detail, no edit — validate the list+detail+sheet pattern here before the Grid.
### 3c. Pipeline (Kanban) — re-think the horizontal board
Today: horizontal kanban columns, one per stage (count + total per column), tap card → edit.
Horizontal columns don't work on a phone.
- **Mobile pattern (lead option):** **swipe between stage columns** — one full-width stage at
a time, snap-scrolling, a stage indicator/segmented control at top, vertical list of
investor cards within each stage. (Alternative to weigh: a vertical accordion of collapsible
stages.) Tap a card → the same investor detail sheet as the Grid. **Editing pipeline stage**
is one of the mobile-editable fields, so make stage changeable here too (a stage picker on the
card, or drag/swipe a card to the next stage).
### 3d. Reminders — a primary tab + an edit surface
A follow-up/tickler list tied to investors (one of the mobile-editable areas).
- **Mobile pattern:** a **list grouped by urgency** (overdue → due-soon → later), each row
showing title, investor, due date (urgency-colored: overdue `#e06c6c`, due-soon `#e0b341`),
and assignee. **Quick actions** (done / snooze / edit) via swipe or a row menu; a **`+`**
creates one. Tap → a **bottom-sheet edit** (title, investor, due date, note, assignee). This
is also the editor reached from an investor detail's "set a reminder" action.
## 4. Brand description (~120 words)
Ten31's CRM is a *trustworthy instrument* — a dense, dark, data-forward venture-fund
workspace for a small team handling sensitive LP relationships. The voice is serious,
discreet, and precise: cool blue-greys, a single confident blue accent, IBM Plex's
engineered-but-humane type, monospace for every number and date. It feels like a well-made
financial terminal, not a consumer app — restraint and legibility over decoration. The
mobile version should feel like the *same instrument in your pocket*: calmer and roomier for
touch, but unmistakably the same tool. It is **not** playful, colorful, skeuomorphic, or
trend-chasing; **not** a second bright color or a borderless/flat reskin. Quiet confidence,
information density made thumb-friendly.
## 5. Inputs to bring to the cloud tool
- **Point at:** `frontend/` (the single `index.html` holds the whole UI — point at the
directory, not the repo root).
- **Upload:** `design/DESIGN.md`, `design/tokens.tokens.json`, `design/brand/ten31-logo-white.svg`,
`design/brand/ten31-favicon.svg`, and **screenshots of each screen** below at desktop width
(Fundraising Grid, Contacts, Pipeline, Dashboard, a modal, the slide-over) so the tool sees
the as-built look it must preserve.
- **Web-capture:** the running app URL if convenient (it's behind auth on the Start9 box; a
set of screenshots is the reliable path).
## 6. Prompt blocks (paste into the design tool, one per screen)
**Global frame (paste first):**
> Redesign this dark venture-CRM web app to be mobile-first and mobile-preferred. **Preserve
> the existing visual language exactly** (see the uploaded DESIGN.md + tokens: dark blue-grey
> palette, single `#3b82c4` accent, IBM Plex Sans/Mono, bordered panels, tinted badges) —
> change only **layout, navigation, and touch interaction**. Small mobile-warranted refinements
> (body type 13→1516px, looser touch density, bottom sheets) are welcome; no visual reskin.
> Mobile carries only **four surfaces in a bottom tab bar — Grid, Pipeline, Reminders,
> Contacts**; every other screen is desktop-only and absent here (keep just a minimal top-bar
> account/logout menu). Convert centered modals and the right slide-over into **drag-to-dismiss
> bottom sheets / full-screen detail views**. 44px touch targets, safe-area-aware sticky bottom
> nav. Design for a 375px phone first.
**Fundraising Grid:**
> This is a 20+ column editable data table; each row is one investor (contact pills + per-fund
> commitments) — unusable as a wide table on a phone. Design a mobile **investor card list**:
> card shows name, committed amount, pipeline stage (only if in the pipeline), and last contact
> (which color-shifts grey→amber→red as it goes stale); plus an auto-derived Existing-Investor star
> and a Priority corner badge — there is **no** investor/prospect type chip. At the top, a
> **tappable current-view name that opens a bottom-sheet list of saved views** (Main, Follow-up,
> All Investors, and a growing set of filtered cuts) plus search; switching a view re-filters
> the list. Tapping a card opens a **full-screen detail**: most fields read-only **except** the
> editable set — **investor name**, **contacts (name + email)**, **notes/communication/outreach**,
> **pipeline stage**, and **set a reminder** — each edited in a bottom sheet. Add a **`+` to
> create a new investor** (name + one or more contacts with name/email; no type, pipeline stage
> optional), with
> search-as-you-type on name to surface existing matches before creating (avoid duplicates).
> Commitments/amounts and the full column set stay read-only / desktop-only.
**Contacts:**
> A read-only people directory (no create/edit here). Today it's a table with All/Investors/
> Prospects tabs and a detail panel. Design a mobile **contact list** (initial, name,
> organization, type badge, last contact), tabs as a top segmented control, search pinned;
> tapping a row opens a **full-screen read-only detail** with contact info, linked investor,
> and communication history.
**Pipeline (Kanban):**
> A kanban board of pipeline stages (one column per stage, each showing count + total),
> cards are investors. Horizontal columns don't fit a phone. Design a **swipe-between-stages**
> mobile view: one full-width stage at a time with snap-scrolling and a stage segmented
> control at top, a vertical list of investor cards within each stage; tapping a card opens
> the investor detail sheet. Stage is editable here (a stage picker on the card, or drag a card
> to the next stage). Also sketch a vertical-accordion alternative for comparison.
**Reminders:**
> A follow-up/tickler list tied to investors. Design a mobile **reminders list grouped by
> urgency** (overdue, due-soon, later); each row shows title, investor, due date (urgency-
> colored), assignee, with quick actions (done / snooze / edit) via swipe or a row menu and a
> `+` to create. Tapping a row opens a **bottom-sheet edit** (title, investor, due date, note,
> assignee).
---
## 7. After the round-trip (Phase C reminder)
Export the **"Handoff to Claude Code" bundle** + screenshots, drop them in
`design/_imports/<date>/`, then distill back into the contract: update `DESIGN.md` §8
(Responsive behavior) with the real mobile-first system, add mobile component states (bottom
nav, sheets, card list) to §4, and bump the mobile type scale in `tokens.tokens.json` if it
changed. The gap between the new contract and the current `index.html` is the implementation
backlog → capture it to `ROADMAP.md` (incl. the inline-style→CSS migration that makes
responsive layout possible at all). Do not silently reskin existing code in the same pass.
+125
View File
@@ -0,0 +1,125 @@
# Ten31 CRM — Design contract (DESIGN.md)
*The durable brand brief. Any agent (or person) building or changing user-facing UI reads
this file and `design/tokens.tokens.json` first and conforms to them. Extracted **as-built**
from `frontend/index.html` on 2026-06-18 (document-as-is); this is a faithful record of the
look that grew in the code, not an aspirational redesign. The mobile-first redesign in
`design/BRIEF.md` builds **on top of** this contract — it changes layout/navigation/touch,
not the visual language below.*
## 1. Visual theme
A dense, professional, **dark** venture-CRM workspace — calm, data-forward, slightly
"terminal/financial." Cool blue-greys throughout, a single saturated blue as the only vivid
accent, and IBM Plex's engineered-but-humane character. Monospace (IBM Plex Mono) carries
all numbers, dates, and codes, reinforcing the data-tool feel. The mood is *trustworthy
instrument*, not consumer-app playful: restraint, legibility, and information density over
decoration. It is used by a ~5-person fund team handling sensitive LP data, so it should
read as serious and discreet.
## 2. Color palette
Canonical values live in `design/tokens.tokens.json`. Summary:
- **Backgrounds (darkest → lightest):** base `#0b1118` → panel `#111a27` → elevated
`#152233` → hover `#1b2a3a`. Recessed input/table-header surface: `#0d1622`.
- **Borders:** default `#263548` (the workhorse — borders, dividers, every table grid line),
strong `#35506a` (hover/emphasis).
- **Text:** primary `#e5edf5`, secondary `#c7d3e0`, muted `#8ea2b7` (most common),
subtle `#70859b`.
- **Accent (the one brand color):** `#3b82c4`. Hover/gradient-end `#2f6ea9`, tint
`#3b82c422`, on-tint text `#93c5fd`. Used for primary actions, active nav, links, focus.
- **Semantic:** success `#10b981`, warning `#f59e0b`/`#fcd34d`, due-soon `#e0b341`,
danger `#dc2626`/`#e06c6c`, error-text `#fca5a5`. Badges render semantic color at low
alpha for the fill with a lighter tint for the text.
White (`#ffffff`) appears only as text on accent fills and in the brand mark.
## 3. Typography
- **Families:** `IBM Plex Sans` (UI/body), `IBM Plex Mono` (numbers, dates, badges, logs,
nav icons). Loaded from Google Fonts; weights 400/500/600/700 sans, 500/600 mono.
- **Scale (as-built):** 11px micro/table-header/badge · 12px help/meta · **13px body / table
/ inputs (desktop base)** · 14px nav · 16px section title · 18px modal title · 20px page
title · 24px login title & KPI value.
- **Treatments:** global letter-spacing `0.01em`; table headers uppercase with `0.08em`
tracking; badges uppercase `0.5px`; numbers use `font-variant-numeric: tabular-nums`.
- **Mobile note:** 13px body is comfortable for desktop and tight on a phone — the redesign
bumps the mobile base toward 1516px. See `BRIEF.md`.
## 4. Component styling
- **Buttons:** primary = top-to-bottom gradient `#3b82c4 → #2f6ea9`, white text, radius 6px,
padding 10×16, lift on hover (`translateY(-1px)` + soft blue shadow). Secondary = flat
`#1b2837`. Danger = `#dc2626`.
- **Cards / sections:** panel `#111a27`, 1px `#263548` border, radius 8px, padding 1620px,
composite drop shadow + inset top highlight. Hover lifts 1px and strengthens the border.
- **Tables:** recessed header `#0d1622`, uppercase muted 11px headers, sticky header +
sticky first column, 1px grid lines, row hover `#172435`, sticky aggregate footer.
- **Forms:** inputs on `#0d1622`, 1px border, radius 6px, accent focus ring; label 13/500,
help 12px muted, error 12px `#fca5a5`.
- **Badges:** radius 4px, 11px/600 uppercase, low-alpha semantic fill + tinted text.
- **Overlays:** modal (centered, radius 12, max-width 500, blurred backdrop) and slide-over
(right drawer, 400px) for detail/edit.
- **Other:** kanban cards (radius 6), toasts (bottom-right), accent spinner, shimmer
skeletons, left-marker timeline for activity feeds.
## 5. Layout
Desktop shell = **fixed 250px left sidebar + flexible main content** with a top header bar
(page title + user). Content max-width 1400px, padding 20px. Primary content patterns:
wide data table (Fundraising Grid), KPI grid + timelines (Dashboard), kanban columns
(Pipeline), list + detail drawer (Contacts/Reminders), two-column (Thesis). Auto-fit grids
are used for KPI cards (`minmax(180px,1fr)`) and kanban columns (`minmax(300px,1fr)`).
## 6. Depth / elevation
Elevation is built from three stacked cues, not just one shadow: (a) a **layered radial-
gradient page background** (two soft blue glows top-left and top-right over `#0b1118`),
(b) panel-color steps (base → panel → elevated), and (c) composite drop shadows with a
1px inset white top-highlight (`inset 0 1px 0 #ffffff07-08`) that gives panels a faint lit
edge. Hover states lift elements 1px and deepen the shadow. Keep this restrained, cool, and
low-contrast — depth should be felt, not seen.
## 7. Do's and don'ts
- **Do** keep `#3b82c4` as the only vivid accent; everything else stays in the blue-grey
range. **Don't** introduce a second bright hue for emphasis — use weight, tint, or the
semantic colors.
- **Do** use IBM Plex Mono for every number/date/code. **Don't** set tabular data in the
sans face.
- **Do** reach for tinted-fill + tinted-text badges for status. **Don't** use solid
saturated fills for status chips.
- **Do** keep borders at `#263548` and lean on them for structure (this is a grid-lined,
bordered aesthetic). **Don't** switch to borderless/shadow-only cards.
- **Do** preserve the restrained 1px hover-lift motion. **Don't** add bouncy or long
animations (and honor `prefers-reduced-motion`, already wired).
## 8. Responsive behavior
**Current (as-built): desktop-first.** Breakpoints are `max-width` (900px, 768px); the
sidebar simply `display:none`s below 768px with no real mobile navigation; wide tables
overflow horizontally; the 400px slide-over overflows a 375px screen. A correct viewport
meta tag is present.
**Target: mobile-first, mobile-preferred — active redesign.** The team increasingly works
from phones, so mobile is becoming the primary surface. The full plan (navigation
re-architecture to a bottom tab bar, table→card transforms, bottom sheets, touch sizing,
type bump) lives in **`design/BRIEF.md`**. Update this section to describe the new
mobile-first system once that redesign lands.
## 9. Agent prompt guide
When building or changing UI here:
- Pull every color, size, radius, and space value from `design/tokens.tokens.json` — do not
hand-pick new hexes. The app still inlines many values; prefer the `:root` CSS variables
(and grow that set) over fresh literals.
- Match the established components above; if you need a new one, compose it from existing
tokens and the bordered-panel + tinted-badge idiom rather than inventing a new visual style.
- Numbers/dates/codes → IBM Plex Mono + tabular-nums. Status → tinted badge. Primary action →
the blue gradient button. Destructive → `#dc2626` and a confirm step.
- **Building anything mobile/responsive?** Read `design/BRIEF.md` first — it holds the
mobile-first layout, navigation, and interaction decisions this section will eventually
absorb.
- Reminder: inline `style={{}}` objects cannot respond to media queries — put any
responsive layout in the CSS `<style>` block (or a utility class), not inline.
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Ten31">
<rect x="2" y="2" width="60" height="60" rx="8" fill="#0b1118" stroke="#ffffff" stroke-width="2"/>
<text x="32" y="41" text-anchor="middle" fill="#ffffff" font-size="24" font-weight="700" font-family="Georgia, 'Times New Roman', serif">T31</text>
</svg>

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 722.69 280.85">
<defs>
<style>
.cls-1 {
font-family: LTCGoudyOldstylePro-Bold, 'LTC Goudy Oldstyle Pro';
font-size: 192px;
font-weight: 700;
}
.cls-1, .cls-2, .cls-3 {
fill: #fff;
}
.cls-2, .cls-4 {
stroke-width: 3px;
}
.cls-2, .cls-4, .cls-3 {
stroke: #fff;
stroke-miterlimit: 10;
}
.cls-4 {
fill: none;
}
.cls-5 {
letter-spacing: -.06em;
}
</style>
</defs>
<text class="cls-1" transform="translate(120.54 208.45)"><tspan class="cls-5" x="0" y="0">T</tspan><tspan x="120.96" y="0">en31</tspan></text>
<g>
<polygon class="cls-3" points="95.52 140.42 54.54 154.4 54.54 126.45 95.52 140.42"/>
<line class="cls-2" x1="0" y1="140.42" x2="60.54" y2="140.42"/>
</g>
<rect class="cls-4" x="97.1" y="1.5" width="527.95" height="277.85"/>
<g>
<polygon class="cls-3" points="721.15 140.42 680.16 154.4 680.16 126.45 721.15 140.42"/>
<line class="cls-2" x1="625.62" y1="140.42" x2="686.16" y2="140.42"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+17
View File
@@ -0,0 +1,17 @@
# Inspiration / reference — Ten31 CRM design
This contract was **extracted as-built** (document-as-is), so the de-facto reference is the
product's own code and brand mark, not an external inspiration set.
- **De-facto design reference:** `frontend/index.html` — the single-file React UI. The
embedded `<style>` block (`:root` vars + component CSS) and ~1300 inline `style={{}}`
objects are the source the contract was harvested from on 2026-06-18.
- **Brand mark / intended palette:** `../brand/ten31-logo-white.svg` (white wordmark),
`../brand/ten31-favicon.svg` (T31 mark), `../brand/ten31-inverted-square.png` (app icon).
White mark on dark `#0b1118` encodes the intended palette: white/light text on a dark cool
ground, with the single `#3b82c4` blue accent.
For the **mobile-first redesign** (`../BRIEF.md`), drop any phone-app screenshots whose
layout/navigation/touch ergonomics you like into this folder (e.g. mobile CRMs, finance apps
with good card/list + bottom-sheet patterns) — they inform layout only; the visual language
stays as captured in `../DESIGN.md`.
+96
View File
@@ -0,0 +1,96 @@
{
"$description": "Ten31 CRM design tokens (W3C DTCG). Extracted as-built from frontend/index.html :root + an inline-style census, 2026-06-18. The app currently inlines these values (CSS :root vars + ~1300 inline style objects); this file is the canonical source going forward. Some real values (composite shadows, the radial-gradient page background) do not map to DTCG primitives and are documented as strings.",
"color": {
"bg": {
"base": { "$type": "color", "$value": "#0b1118", "$description": "Page background (darkest layer). Also the de-facto theme color; use for a future PWA manifest theme_color." },
"panel": { "$type": "color", "$value": "#111a27", "$description": "Cards, sections, modals, sidebar, slide-over." },
"elevated": { "$type": "color", "$value": "#152233", "$description": "Elevated/hover panel state." },
"hover": { "$type": "color", "$value": "#1b2a3a", "$description": "Generic hover background." },
"input": { "$type": "color", "$value": "#0d1622", "$description": "Form input + table-header background (recessed)." }
},
"border": {
"default": { "$type": "color", "$value": "#263548", "$description": "All borders, dividers, table grid lines. The most-used non-text color." },
"strong": { "$type": "color", "$value": "#35506a", "$description": "Emphasized border / card hover border." }
},
"text": {
"primary": { "$type": "color", "$value": "#e5edf5", "$description": "Headings, primary content." },
"secondary": { "$type": "color", "$value": "#c7d3e0", "$description": "Body text, labels." },
"muted": { "$type": "color", "$value": "#8ea2b7", "$description": "Hints, metadata, table headers. Highest-frequency text color." },
"subtle": { "$type": "color", "$value": "#70859b", "$description": "Very secondary labels / inactive tabs." }
},
"accent": {
"default": { "$type": "color", "$value": "#3b82c4", "$description": "Primary action, active nav, links, focus ring. The one vibrant brand color." },
"strong": { "$type": "color", "$value": "#2f6ea9", "$description": "Accent hover / gradient endpoint." },
"soft": { "$type": "color", "$value": "#3b82c422", "$description": "Accent at ~13% alpha — tinted badge/active backgrounds." },
"light": { "$type": "color", "$value": "#93c5fd", "$description": "Accent text on dark tinted backgrounds (badges, pills)." }
},
"semantic": {
"success": { "$type": "color", "$value": "#10b981", "$description": "Money / positive values." },
"success-text": { "$type": "color", "$value": "#6ee7b7" },
"warning": { "$type": "color", "$value": "#f59e0b", "$description": "Advisor / warning." },
"warning-text": { "$type": "color", "$value": "#fcd34d" },
"due-soon": { "$type": "color", "$value": "#e0b341", "$description": "Reminder due-soon urgency." },
"danger": { "$type": "color", "$value": "#dc2626", "$description": "Destructive action button." },
"danger-soft": { "$type": "color", "$value": "#e06c6c", "$description": "Overdue / error emphasis." },
"danger-text": { "$type": "color", "$value": "#fca5a5", "$description": "Inline error text." }
},
"constant": {
"white": { "$type": "color", "$value": "#ffffff", "$description": "Text on accent fills; brand mark." }
}
},
"font": {
"family": {
"sans": { "$type": "fontFamily", "$value": ["IBM Plex Sans", "Avenir Next", "Segoe UI", "sans-serif"] },
"mono": { "$type": "fontFamily", "$value": ["IBM Plex Mono", "monospace"], "$description": "Numbers, dates, badges, logs, nav icons." }
},
"size": {
"xs": { "$type": "dimension", "$value": "11px", "$description": "Table headers, badges, micro-labels." },
"sm": { "$type": "dimension", "$value": "12px", "$description": "Help text, metadata." },
"md": { "$type": "dimension", "$value": "13px", "$description": "Body / table cells / inputs (current desktop base). NOTE: bump toward 1516px for mobile body — see BRIEF.md." },
"lg": { "$type": "dimension", "$value": "14px", "$description": "Nav items." },
"xl": { "$type": "dimension", "$value": "16px", "$description": "Section titles." },
"2xl": { "$type": "dimension", "$value": "18px", "$description": "Modal titles." },
"3xl": { "$type": "dimension", "$value": "20px", "$description": "Page/header title." },
"4xl": { "$type": "dimension", "$value": "24px", "$description": "Login title, KPI values." }
},
"weight": {
"regular": { "$type": "fontWeight", "$value": 400 },
"medium": { "$type": "fontWeight", "$value": 500 },
"semibold": { "$type": "fontWeight", "$value": 600 },
"bold": { "$type": "fontWeight", "$value": 700 }
}
},
"space": {
"2xs": { "$type": "dimension", "$value": "4px" },
"xs": { "$type": "dimension", "$value": "6px" },
"sm": { "$type": "dimension", "$value": "8px" },
"md": { "$type": "dimension", "$value": "12px", "$description": "Most common padding/gap unit." },
"lg": { "$type": "dimension", "$value": "16px" },
"xl": { "$type": "dimension", "$value": "20px", "$description": "Sidebar/header/content padding." },
"2xl": { "$type": "dimension", "$value": "24px", "$description": "Modal padding." }
},
"radius": {
"sm": { "$type": "dimension", "$value": "4px", "$description": "Badges." },
"md": { "$type": "dimension", "$value": "6px", "$description": "Buttons, inputs, kanban cards." },
"lg": { "$type": "dimension", "$value": "8px", "$description": "Cards, nav items, sections." },
"xl": { "$type": "dimension", "$value": "12px", "$description": "Modals." },
"pill": { "$type": "dimension", "$value": "999px", "$description": "Pills, skeleton lines." }
},
"shadow": {
"$description": "Real composite shadows from the as-built CSS; kept as raw strings (multi-layer + inset highlight don't map to a single DTCG shadow token).",
"card": { "$type": "shadow", "$value": "0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07" },
"card-hover":{ "$type": "shadow", "$value": "0 10px 20px rgba(7,17,30,0.35)" },
"button-hover": { "$type": "shadow", "$value": "0 6px 14px rgba(12,40,68,0.35)" },
"modal": { "$type": "shadow", "$value": "0 24px 56px rgba(1,8,17,0.5), inset 0 1px 0 #ffffff08" },
"slide-over":{ "$type": "shadow", "$value": "-12px 0 32px rgba(4,10,18,0.45), inset 1px 0 0 #ffffff07" },
"toast": { "$type": "shadow", "$value": "0 10px 24px rgba(4,12,22,0.45)" }
},
"motion": {
"fast": { "$type": "duration", "$value": "120ms", "$description": "Press/transform feedback." },
"base": { "$type": "duration", "$value": "150ms", "$description": "Hover color/shadow." },
"panel": { "$type": "duration", "$value": "300ms", "$description": "Slide-over / toast entry." }
},
"_unmappable": {
"$description": "Documented-but-not-tokenized: the page background is a layered radial-gradient ('radial-gradient(1200px 600px at 15% -10%, #1a3c5e44, transparent 60%), radial-gradient(1000px 500px at 90% 0%, #27496b33, transparent 58%), #0b1118') — see DESIGN.md §Depth/elevation."
}
}
+22
View File
@@ -0,0 +1,22 @@
# Runs the Matrix intake bot as a managed container on the Spark (spark-32d0, user `modelo`).
#
# `restart: unless-stopped` is the actual durability fix — the bot now survives a Spark reboot
# (it was a bare nohup process before, which silently died on reboot). `network_mode: host` so
# it can reach Matrix (clearnet TLS), the CRM API (box LAN), and Spark Control (local Qwen).
# The container name is fixed so a future spark-control dashboard card can find it by
# `docker inspect matrix-intake` (see docs/handoffs/add-intake-bot-to-spark-control.md).
#
# Deploy / update on the Spark: docker compose up -d --build
# Logs: docker logs -f matrix-intake
# Stop: docker compose down (or: docker stop matrix-intake)
services:
intake:
build:
context: .
dockerfile: backend/matrix_intake/Dockerfile
image: matrix-intake-bot
container_name: matrix-intake
network_mode: host
restart: unless-stopped
volumes:
- ./.env:/app/.env:ro
+74 -5
View File
@@ -12,7 +12,7 @@ Read this before editing Gmail capture or draft creation.
## What it does ## What it does
- `backend/email_integration/` captures Gmail via **domain-wide delegation** (`credentials.py`, `matcher.py`, `parser.py`, `db.py`, `sync.py`, `scheduler.py`, `routes.py`) and creates Tier-B in-thread drafts (`compose.py`). It has its own `migrations/`. - `backend/email_integration/` captures Gmail via **domain-wide delegation** (`credentials.py`, `matcher.py`, `parser.py`, `db.py`, `sync.py`, `scheduler.py`, `routes.py`) and creates Tier-B in-thread drafts (`compose.py`). It has its own `migrations/`.
- Captured email becomes CRM activity through a **propose → approve** flow — nothing lands on a contact record until a human approves the proposal. - Captured email becomes CRM activity through a **propose → approve** flow — nothing lands on a contact record until a human approves the proposal. The proposed grid notes show on the **Email Capture** page (admin-only): each card has a **View email** toggle that fetches `GET /api/email/detail?id=` and shows the source email inline (from/to/cc/date/subject + scrollable body) so you can judge the note against it. The same proposals can also be reviewed/approved/edited from a **dedicated Matrix room**, kept in sync with this panel (decide on either surface; the other reflects it) — that CRM→Matrix bridge lives in the **review bot**, see `docs/guides/matrix-intake.md`. The proposal model itself (`email_activity_proposals` + the `propose_email_activity_notes` drafter + the decide path) lives in `backend/server.py`, not this package.
## Hard rule ## Hard rule
@@ -70,16 +70,85 @@ different category. **Never extend this path to send to LPs/prospects.**
the panel takes effect with no restart. Content window = (last send, now]; cursor the panel takes effect with no restart. Content window = (last send, now]; cursor
(`digest_last_sent_at`) + once-per-day guard (`digest_last_sent_date`) live in `app_settings`, (`digest_last_sent_at`) + once-per-day guard (`digest_last_sent_date`) live in `app_settings`,
so a missed day rolls into the next digest. Recipients = all active admins. so a missed day rolls into the next digest. Recipients = all active admins.
- **On-demand: `POST /api/admin/digest/send-now`** (admin-only) → `maybe_send_digest(force=True)` - **Windowed preview + manual send (Settings → Admin "Manual run & preview"):**
builds the real last-24h digest and sends to the admin set regardless of the policy and - **`POST /api/admin/digest/preview`** (admin-only) builds the digest over a chosen window
**without** touching the daily cursor (a preview never suppresses the scheduled send). and returns `{subject, body, …, window}` **without sending** — it runs the **real Spark
Surfaced as a "Send Digest Now" button in Settings → Admin, beside "Send Test Digest Email". summarization**, so widening the window is how you verify the summarizer on a quiet day
(a last-24h window with no activity never calls Spark). Rendered in an in-panel preview.
- **`POST /api/admin/digest/send-now`** (admin-only) sends over the **same** window to the
admin set now.
- Both take the window from the body: default last 24h, `{"hours": N}`, or
`{"since": "YYYY-MM-DD"}` (a **local** date → that day's midnight). Resolved by
`digest_builder.resolve_digest_window` (capped at `MAX_WINDOW_DAYS`=92, validated → 400 on
bad input). The send goes through `digest_scheduler.send_digest_window`, which — like the
old `force=True` path — **does NOT advance the daily cursor**, so a wide manual preview/send
never suppresses the scheduled daily digest.
- The **"Send transport test"** button (`POST /api/admin/digest/test-email`) stays as a pure
pipe check (fixed message, admin-recipient-restricted).
- **Decisions (locked):** 6 PM default send · always-send (empty days get a "no activity" - **Decisions (locked):** 6 PM default send · always-send (empty days get a "no activity"
note) · per-user narrative + by-investor structured section · enable/time controlled in the note) · per-user narrative + by-investor structured section · enable/time controlled in the
admin panel. Tests: `backend/test_digest_builder.py` (per-user + per-investor queries, admin panel. Tests: `backend/test_digest_builder.py` (per-user + per-investor queries,
soft-delete, inbound dedup, two-section compose, fallback, policy resolver, scheduler guards soft-delete, inbound dedup, two-section compose, fallback, policy resolver, scheduler guards
— stubbed LLM + transport). — stubbed LLM + transport).
## Email-activity panel (Communications tab) — admin-only
The **Communications** tab (frontend) is the admin-only search over captured Gmail. The
classic manual "Log Communication" form was retired (the Fundraising Grid context menu is
the manual-log path). Backed by **`GET /api/email/activity`** (`routes.py:_h_activity`,
`require_admin` server-side) → **`db.query_email_activity(conn, ...)`** (the pure, tested
query). Filters: `investor_id`, `account_id` (mailbox), `direction` (`inbound`/`outbound`),
`q` (free-text over subject/snippet/from). Non-obvious semantics to preserve:
- **Matched-only:** the panel surfaces ONLY email that links to a known
investor/contact (`query_email_activity` gates on `EXISTS email_investor_links`).
Capture still stores unmatched cold/unknown-sender email (metadata only, see "match-only
full storage"), but it is never shown here — the Communications tab is the
investor-relationship view, not the raw mailbox.
- **Soft-delete lives on the per-mailbox sighting**, not the email: `emails` has no
`deleted_at`. An email is "live" iff it has a sighting with `email_account_messages.
deleted_at IS NULL` — the query gates on `EXISTS(... deleted_at IS NULL)`. (Investor
links are email-level and carry no `deleted_at`, so they need no separate filter.)
- **Direction is decided at the email level** — outbound if `from_email` is one of our
`email_accounts` addresses, else inbound — mirroring `digest_builder._own_addresses`.
- **Graveyard investors** are hidden from the filter *dropdown* (CRM-wide `graveyard = 0`),
but their captured email still shows in the list and stays findable by free-text search —
it's an audit surface, so history is never hidden, only the picker is.
- **Typed investor facet (the dropdown).** The picker mirrors what the list resolves: one
entry per distinct matched entity, with the digest's precedence (**grid investor → org →
contact → raw address**) and a **typed key** — `fund:<id>` / `org:<id>` / `contact:<id>`
(`investor_id=` accepts these; a bare id is treated as `fund:` for back-compat). This fixed
the "dropdown only shows *All investors*" bug: matches that land on a **classic contact or
org domain** (no grid id — common, since `fundraising_contacts.email` is sparsely populated)
now resolve to a real name and appear in the picker, instead of the facet coming back empty.
Raw-address-only matches stay out of the *picker* (noisy) but still show + search in the list.
Helpers: `db._resolve_entity` + the shared `_LINK_IDENTITY_COLS`/`_LINK_IDENTITY_JOINS`.
- **Date range:** `since`/`until` filter `e.sent_at` as a half-open `[since, until)`
interval; the UI sends `from` as `…T00:00:00` and `to` as the **next day's** midnight,
so the whole "to" day is included regardless of the stored timestamp's precision/zone.
- **Detail view:** **`GET /api/email/detail?id=`** (`_h_detail`, `require_admin`) →
`db.query_email_detail` returns the full body + to/cc recipients + attachments + typed
identities, **soft-delete-gated on a live sighting** (404 otherwise). The UI renders
`body_text` (escaped) — **never** raw remote `body_html` (XSS); click a row to expand.
## Content search (semantic, over email bodies) — admin-only
The Communications tab has a **Filter ⇄ Search content** toggle. "Search content" is semantic
search over the email *bodies* indexed in Qdrant (distinct from the structured subject/sender
LIKE filters above). **`GET /api/email/search?q=`** (`routes._h_search`, `require_admin`):
- Retrieval = `ingest/search.py:hybrid_search` (dense + BM25, reranked) pre-filtered to
`doc_type='email'`, imported **lazily** (the ingest stack — Spark Control + Qdrant + the
sparse encoder — ships in the Docker image, not the bare CRM); any failure → a clean **503**.
- Only **matched** email bodies are indexed (see `ingest/chunking.py`); the Qdrant payload
carries `source_id`=email_id, `lp_name`, `date_ts`, so hits link straight back to the row.
- **Hydrated + soft-delete-filtered against SQLite (canonical):** `db.search_hit_emails`
drops any hit whose email no longer has a live sighting — the derived index can lag a
deletion, and we never surface a fact from Qdrant that SQLite has tombstoned.
Tests: `backend/email_integration/test_email_activity_panel.py` (panel filters/facets/detail +
the search route's hydrate/drop/503/admin paths, with retrieval stubbed).
## Known gap ## Known gap
- Tier-B drafts currently reply to the **LP only**; reply-all is the next change (see AGENTS.md → Current state). - Tier-B drafts currently reply to the **LP only**; reply-all is the next change (see AGENTS.md → Current state).
+297
View File
@@ -0,0 +1,297 @@
---
paths:
- backend/matrix_intake/**
---
# Matrix intake bot
Read this before editing `backend/matrix_intake/`. The bot turns a typed message in a
dedicated Matrix room into a proposed fundraising-grid add/edit, gated on **in-thread human
approval** before any write. Phase status: **M1 + M2 deployed & live** (text intake + approval + write; bot on the Spark,
CRM endpoints on the box at **v0.1.0:86**; live-smoked 2026-06-17). **M3 (business-card photo)
deferred** — Spark Control has no vision model yet.
**Post-deploy UX pass — DEPLOYED & LIVE 2026-06-17:** fuzzy investor matching (server-side,
**v0.1.0:86**, installed to the box — `candidates` endpoint verified live) + in-thread
disambiguation and conversational natural-language edits (bot-side, pulled + restarted on the
Spark). See *Fuzzy matching* below. Tests green (27/27 backend + the offline bot suite); the
**Matrix live-smoke** of the disambiguation grammar and the Qwen `revise` leg is still pending.
## What it is (and isn't)
- A **separate process**, not part of the CRM. Its only third-party dep, `matrix-nio`, lives
in `backend/matrix_intake/requirements.txt` and **must never** be added to the stdlib CRM
(`backend/server.py`). Runs on the Spark (placement per `standards/guides/placement.md`).
- It **drafts; a human approves.** Nothing is written autonomously — every CRM write follows a
`yes` reply in the proposal thread. This is exempt from "agents draft, humans send" the same
way the digest is: it's internal data entry to our own CRM, not outward LP contact.
- It is **not** a parallel write path. It reuses the CRM's own canonical endpoint
`POST /api/fundraising/log-communication` (create-if-missing + contact upsert + note +
relational sync + audit) for both new-investor and existing-note cases. Don't reimplement
grid mutation in the bot.
## Flow
1. Top-level message in the intake room → `parse.parse_message` → local **Qwen via Spark
Control** (`spark.py` reuses `backend/ingest/llm.py`; temp 0, JSON only) extracts
`{intent, investor_name, contact_name, contact_email, contact_title, note}`. The original
message text is stashed on the proposal as `_source_text` (needed later for `revise`'s
email-integrity check). The system prompt is built by `parse.build_system(roster)`, which —
when a **team roster** is configured (`INTAKE_TEAM_ROSTER`, see *Config*) — appends an
**outreach frame**: those names are our own team members *doing* the outreach, so a teammate's
name is never extracted as the investor/contact and the *other* party is the prospect. Fixes
the live-smoke gripe where *"jonathan is chatting with wyoming"* picked the teammate, not the
prospect. `revise` gets the same framing. Roster unset → prior behavior (no frame).
2. `crm_client.match` (`GET /api/intake/match`) resolves new-vs-existing. It returns **both** an
exact `match` (returns the **grid row id** so an approved note lands on exactly that investor,
no duplicate) **and**, when there's no exact match, a ranked list of fuzzy `candidates` (see
*Fuzzy matching* below).
3. Three outcomes drive what gets posted, all **in a thread** rooted at the user's message, plus a
brief **main-timeline nudge** (a plain reply — `matrix_io.make_reply`) so it isn't missed:
- **Exact match** → auto-attach: proposal flips to `meeting_note` with `_match_id` set, rendered
as the normal approval card.
- **Fuzzy candidates, no exact** → a **disambiguation** card (`proposals.render_disambiguation`):
the proposal is held at `_stage="disambiguate"` with `_candidates`, and the human must pick a
**number** / `new` / `no` before it becomes an approval-stage proposal.
- **Neither** → the new-investor approval card.
The nudge is a **pointer only, not a reply target** — you need the thread to act. The pending
proposal is held in memory keyed by the thread root (`proposals.ProposalStore`).
4. User replies **in the thread**. `handle_reply` branches on `_stage`:
- **disambiguate** (`handle_disambiguation`): a number attaches to that candidate (→ `meeting_note`
+ `_match_id`, re-rendered for approval); `new` proceeds as a new investor; `no` discards.
- **approval**: `yes` commits; `no` discards; `edit field=value` is the deterministic fast-path
edit; **anything else is treated as a natural-language revision**`parse.revise` sends
`{current proposal + instruction}` back through local Qwen and re-renders the revised card (a
no-op revision is detected via `proposals.same_fields` and re-prompts instead of saying
"Updated"). On `yes`, `crm_client.commit` POSTs to `log-communication` tagged
`source="matrix_intake"` (provenance in the audit log).
A bare `yes`/`no` typed **top-level** (not in the thread) while a proposal is pending gets a
"reply in the thread" redirect (`store.any_pending()` guard in `handle_intake`), not a
misparsed new intake.
## Fuzzy matching (server-side, ships in the s9pk)
`GET /api/intake/match` returns `{match, candidates}`. `find_intake_match` is unchanged —
**exact-after-normalization**, and an exact match still auto-attaches without disambiguation.
`find_intake_candidates` (new) is the fuzzy layer, **deterministic, no LLM**: it scans the same
canonical grid blob and scores each row by `max(`name similarity`, `email near-match`)`, keeping
rows ≥ `min_score` (0.62), ranked, capped at 5:
- **Name** (`_name_similarity`): max of stdlib `difflib` sequence ratio (near-spellings —
"Charlie"/"Charles") and token-set Jaccard (word-order). **Legal-entity suffixes**
(LLC/LP/Inc/… via `_strip_legal_suffix`) are stripped first, so "Acme Capital" ~ "Acme Capital
LLC" scores 1.0 (a near-certain duplicate `find_intake_match` misses because it compares the
full string) — and is surfaced as a candidate, **never auto-attached** (the human still confirms).
- **Email** (`_email_edit_distance`): Levenshtein ≤ 2 against each contact email (dist 1→0.9,
2→0.8). Distance 0 is an exact email — that's `find_intake_match`'s job, skipped here.
- **Recall-favoring by design:** a shared common name-word ("… Capital") can lift an unrelated firm
into the 0.60.8 band. Acceptable — it's a *ranked, human-confirmed* shortlist, and the cost of an
occasional stray suggestion is far lower than missing a real near-duplicate. **Semantic pruning of
the shortlist (the "Charlie really is Charles" judgment) is a deferred LLM-judge re-rank** — fed
only the shortlist, never the whole LP list — intentionally NOT built in this pass, because the
deterministic filter already surfaces every duplicate the human then resolves.
## Email-activity proposal review (the CRM→Matrix bridge, v0.1.0:89)
A second, separate flow runs alongside intake: reviewing the **proposed grid notes** the CRM
drafts from newly-matched email (`server.propose_email_activity_notes`, surfaced on the web Email
Capture panel). The bot lets the team approve/dismiss/edit those on mobile, kept **in sync** with
the web panel. The CRM (box, stdlib, no matrix-nio) can't post to Matrix, so the bot **pulls**.
- **Dedicated room** (`MATRIX_EMAIL_REVIEW_ROOM`, see *Config*) — separate from the intake room
so high-volume email proposals don't drown the conversational intake. Unset → the whole leg is
off (the bot just does intake). The bot must be a **member** of this room.
- **Poll loop** (`bot.poll_email_proposals`, every `EMAIL_POLL_SEC`=20s) calls `crm_client.
list_email_proposals` → `GET /api/intake/email-proposals`, which returns three work-lists:
- **to_post** — pending, not yet posted → the bot posts a review card (metadata + a short email
**snippet** + the drafted note; the full body is the web popup's job, kept compact for mobile),
then records the thread-root event id via `POST .../{id}/matrix {event_id}`.
- **open** — pending, posted, not closed → the bot rebuilds its `event_id → proposal` routing map
from these on **every poll**, so replies still route **after a bot restart** (unlike intake's
in-memory-only store — the state lives CRM-side in `email_proposal_matrix`).
- **to_close** — decided on the **web** while a thread was open → the bot clears it (see redaction
below) and `POST .../{id}/matrix {closed:true}`.
- **In-thread replies** (`bot.handle_email_reply`, `email_proposals.interpret`): `yes` →
`POST .../{id}/decide {decision:"approve", note}` (appends the note to the grid, source='matrix',
closes the thread atomically); `no` → dismiss; **anything else → NL revision of the note** via
local Qwen (`email_proposals.revise_note`, no Claude/scrub) — re-rendered for re-approval, so the
draft→approve gate holds. A no-op/empty revision re-prompts instead of saying "Updated".
- **Card formatting:** `email_proposals.render_card` frames every card/reply with a `RULE` dash line
top and bottom (`frame()`) so threads don't bleed together on mobile, and the note **names who
emailed whom** ("{teammate} emailed {investor}" / "{sender} emailed the team") rather than a bare
Sent/Received — the wording is built server-side in `propose_email_activity_notes`.
- **Decided threads are redacted, not just closed.** On any conclusive decision (Matrix or web) the
bot calls `redact_thread(root)`: redact the card, then scan recent history (`room_messages`,
`MessageDirection.back`) for that root's `m.thread` replies and redact those too — so a resolved
thread clears from the **threads view**, not only the timeline. **No confirmation is posted on
success** (the thread vanishing is the ack; a confirmation reply would keep the thread alive).
- **Needs the bot to hold a `redact`/moderator power level** in the review room — required to
redact the *human's* yes/no reply (its own card needs no power). Without it, the reply lingers.
- **Full clearing depends on a client setting:** redaction removes the events, but Element shows a
"Message deleted" placeholder by default — turn OFF "show removed/deleted messages" in Element and
both the main chat and the threads view clear completely. (Verified the intended UX 2026-06-18.)
- **One-time backfill:** `backend/matrix_intake/redact_resolved.py` (dry-run default; `--apply`)
clears threads decided *before* this shipped (already `closed`, so the poll's to_close never
touches them). Run on the Spark: `docker compose run --rm intake python -u
backend/matrix_intake/redact_resolved.py [--apply]`. It keeps cards still pending (CRM `open`)
and redacts every other card + its replies.
- **Two surfaces, one source of truth.** Decide on the web → the bot redacts + closes the thread;
decide on Matrix → the web panel polls `/api/activity/proposals` (~25s) and the card clears.
`email_proposal_matrix` (1:1 side row, migration `0003`) carries `event_id`/`posted_at`/`closed_at`;
a matrix decision sets `closed_at` in the same txn so it's never re-processed via `to_close`.
- **Pure logic is `email_proposals.py`** (card render, reply grammar, note revision) — unit-tested
offline in `test_email_proposals.py`; the async poll/post wiring is in `bot.py` (live-smoke only).
- **Known minors (low-likelihood, ~5-person team):** if the CRM is unreachable *between* posting a
card and recording its event id, the next poll re-posts a duplicate card (the orphan's replies
won't route — re-send/decide the recorded one). A mid-revise bot restart loses the in-memory
revised note (rebuilt from `open` = the original `proposed_note`; still a valid proposal).
## NL query — read-only Q&A (W2 step 5)
A read-only "ask the database in plain English" flow, answered in-thread. **No write path, no
approval gate** — it only runs the curated, parameterized queries behind the CRM's NL-query
endpoint, so it's exempt from the draft→approve dance the write flows need. Two entry points,
same `handle_query` → `crm_client.nl_query` underneath:
- **Dedicated Q&A room** (`MATRIX_QUERY_ROOM`, recommended) — **every** top-level message is a
question; no trigger needed. This is the room-per-purpose model (intake / email-review / Q&A,
with a future reminders-push room): the trigger grammar below exists *only* to disambiguate
question-vs-note when Q&A shares the intake room, which a dedicated room makes unnecessary. The
simplest room of the three — read-only, no approval, no redaction, **no special power level**.
- **`@bot`/`?` trigger in the intake room** (cross-room convenience) — fire a quick question
without switching rooms. `query.parse_trigger` (pure/tested) matches a top-level message starting
with `?`, `@bot`, `/ask`, `/query`, or `/q`. The trigger is **required** there, so plain intake
notes still route to intake. A bare leading `ask` is deliberately **not** a trigger — it would
collide with notes like *"Ask Jane to send the deck"*. A bare trigger (`@bot` alone) posts help.
- **One endpoint call** (`crm_client.nl_query` → `POST /api/query/nl {question, source:"matrix"}`):
translation runs on the box's **local Qwen** (nothing leaves the box; **no Claude, no scrub** —
same basis as intake) and only the fixed `nl_query` catalog can run. The bot is a thin client —
see `docs/guides/nl-query.md` for the trust model.
- **Rendering** (`query.render_answer`, pure/tested): a deterministic Matrix-markdown answer
(summary + interpreted intent + compact rows, money/date formatting, nested contacts/commitments
for `investor_lookup`). **Results never go back to any model.** Mobile soft-cap `MAX_DISPLAY_ROWS`
(30) with an explicit "+N more" note — never a silent cut.
- **Status passthrough:** the endpoint returns its structured body on a hit *and* on the soft
503 (model down) / 500 (query fault) codes, so `nl_query` hands those to the renderer; only an
auth/shape failure (403/400) raises → a brief ⚠️ in-thread.
- **Ships on the Spark** (bot-side, `query.py` + `crm_client.nl_query` + `bot.py` wiring) via
`git pull` + restart — **no s9pk for the bot**. **But it depends on the box-side `/api/query/nl`
endpoint**, which ships in the s9pk and is **not live until v93** (reminders + W2). Deploying the
bot before that = a Q&A room that 404s every question (same server-side/bot split as the v83→v84
`/api/intake/match` 404). **Sequence: install v93 first, then** set `MATRIX_QUERY_ROOM` + invite
the bot + restart. Pure logic tested in `test_query.py` (+ `nl_query` cases in
`test_crm_client.py`); the in-room smoke (a bare message in the Q&A room, or `?…` in the intake
room) is live-only.
## Rules / gotchas
- **Module-name collision:** the intake config module is `settings.py`, **not** `config.py`,
because `backend/ingest/config.py` is imported (as bare `config`) through `spark → llm`. A
second `config` module would shadow it in `sys.modules` and break `llm` (`CHAT_MODEL`).
Keep intake module names from colliding with ingest's (`config`, `http_util`, `llm`).
- **Email integrity:** `parse.normalize` only keeps an address that literally appears in the
source message — the model must never mint one (a wrong email is worse than none). It takes
the **first** address in the text, so a two-person message ("Alice a@x.com and Bob b@y.com")
could attach the wrong one; the human sees it in the proposal and can `edit email=…` before
approving. Cross-referencing multiple addresses to the named contact is a deliberate non-goal
for v1.
- **Conversational revise keeps the email rule:** `parse.revise` re-runs a free-form correction
through Qwen but **never trusts the model's email field**. A changed address is accepted only if
it literally appears in the *instruction text* (searched first), else the existing
integrity-checked address is kept (`_apply_revision`). The model can edit name/contact/title/note
freely but cannot mint an email. A revision that nulls both investor and contact is rejected (the
proposal can't be emptied to something unactionable). Revise edits fields on the current proposal;
it does **not** re-run the matcher if you rename the firm mid-thread (a known v1 limit — the human
still approves).
- **Deploy is split across two surfaces** (mind which one carries a change): the fuzzy
**`candidates`** come from `server.py` → ship in the **s9pk** (build + install, version-bumped).
The bot's **disambiguation flow + `revise`** live in `backend/matrix_intake/` → ship on the
**Spark** via `git pull` + restart. A bot restart alone won't deliver `candidates` (the box would
return an empty list and the bot just proposes new — safe, but no fuzzy surfacing until the s9pk
is installed). Same lesson as the v83→v84 `/api/intake/match` 404.
- **Double-approve guard:** `handle_reply` pops the pending proposal from the store *before*
awaiting the commit, so a second `yes` arriving mid-write is a no-op (asyncio is cooperative;
the pop is atomic w.r.t. other events). On commit failure the proposal is restored for retry.
*Known minor:* in the **disambiguate** stage the pick re-stores an approval-stage proposal
before its `await say`, so a rapidly-repeated `1` can have the second one fall through to the
NL-revise path (a wasted Spark round-trip that re-prompts) — harmless, nothing commits, not
guarded (low likelihood on a ~5-person team).
- **Local-only parse:** intake text is real LP substance but goes ONLY to local Qwen via Spark
Control, never Claude — so no scrub boundary applies (same basis as the digest). Never call a
Spark directly; always go through `SPARK_CONTROL_URL`.
- **Auth:** the CRM has no service-key path; the bot logs in as a dedicated CRM user
(`CRM_BOT_USERNAME`/`CRM_BOT_PASSWORD`) → Bearer JWT, re-login once on 401.
- **Tests** are offline: `test_parse.py` / `test_proposals.py` / `test_crm_client.py` stub the
network; `backend/test_intake_endpoints.py` boots the real server against a temp DB and
covers `/api/intake/match` + the create→match (no-duplicate) contract + provenance. A **live
Matrix smoke** needs creds + `matrix-nio` installed on the Spark — it can't run in CI.
- **Grid note line:** the bot sends a **blank `subject`** when there's a note so the CRM's
one-line note summary shows the note text (the CRM renders subject-or-body); a provenance
label is sent only when there's no note. v0.1.0:85 also dropped the redundant `[note]` type
tag from that server-side line (informative types like `[call]` keep theirs).
## Deployment & ops
- **Runs on the Spark as a docker container** (`matrix-intake`), since 2026-06-17 — SSH alias
`modelo32`, host `spark-32d0`, repo clone at `/home/modelo/ten31-database`. Defined by
`docker-compose.yml` at the repo root + `backend/matrix_intake/Dockerfile`. The image bundles
`backend/matrix_intake/` **and** `backend/ingest/` (spark.py reaches into the latter's stdlib
Spark client via sys.path); `.env` is mounted read-only at `/app/.env`. `network_mode: host`
so it reaches Matrix, the CRM, and Spark Control. Startup logs `listening as … in room …`.
- **Survives a Spark reboot** via `restart: unless-stopped` — the durability fix that retired the
old bare `nohup` launch. (The previous nohup method + `/tmp/intake-bot.pid` are gone.)
- **Deploy / update after a `git pull`:** `cd /home/modelo/ten31-database && git pull && docker
compose up -d --build`. **Logs:** `docker logs -f matrix-intake`. **Restart:**
`docker restart matrix-intake`. **Stop:** `docker compose down`. A restart still **drops
in-memory pending proposals** (re-send to recover).
- **Not yet a spark-control dashboard card.** The container is managed via `docker`/SSH today; a
managed card (Update/Restart/Stop/Logs tile, like `matrix-bridge`) is a separate spark-control
task — see `docs/handoffs/add-intake-bot-to-spark-control.md`.
- **Gotcha — the repo-root `.dockerignore` is SHARED** with the s9pk build (`start9/0.4/Dockerfile`,
same repo-root context). Don't add bot-only exclusions (e.g. `frontend/`, `docs/`) to it — you'd
break the CRM image build, which needs them. It already excludes the security-critical bits
(`data/`, `.env`), which is all the bot build needs.
- **Server-side endpoints ship in the s9pk, not the bot.** `GET /api/intake/match` and the
`source` provenance on `log-communication` live in `backend/server.py`, so they reach the box
only via an **s9pk build + install** — a bot restart won't deliver them. (Missed in v83: the
box 404'd `/api/intake/match` until **v0.1.0:84**.) **Same split for the email-review bridge
(v0.1.0:89):** the `/api/intake/email-proposals*` endpoints + the `email_proposal_matrix`
migration (`0003`) + the `bot` role ship in the **s9pk**; the poll loop + review-room handling
ship on the **Spark** (git pull + restart). A bot restart against a pre-v89 box returns nothing
useful (404/empty), so install the s9pk first, then set the bot user's role + the review room.
- **`CRM_API_BASE` is the box over the LAN, not localhost** (bot on the Spark, CRM on the box).
`https://immense-voyage.local` (443) is the **StartOS dashboard**, not the CRM — the CRM has
its own interface address (the URL you open in a browser); container port 8080 isn't
LAN-reachable.
## Config
All in `.env` (names in `.env.example`): `MATRIX_HOMESERVER`, `MATRIX_USER`,
`MATRIX_ACCESS_TOKEN`, `MATRIX_DEVICE_ID`, `MATRIX_INTAKE_ROOM`; `CRM_API_BASE`,
`CRM_BOT_USERNAME`, `CRM_BOT_PASSWORD`, `CRM_API_VERIFY_TLS`. Spark settings are inherited from
the ingest client (`SPARK_CONTROL_URL`, `CRM_CHAT_MODEL`).
- **`MATRIX_EMAIL_REVIEW_ROOM`** (optional) — the dedicated room for the email-activity proposal
review leg (above). Unset/empty disables that leg entirely (the bot does intake only). The bot
must be invited to + joined in this room. Read once at startup, like the room/roster.
- **`MATRIX_QUERY_ROOM`** (optional) — the dedicated read-only Q&A room (NL query section above).
In it, every top-level message is answered as a query (no `?`/`@bot` trigger). Unset/empty just
means no dedicated room — questions still work via the trigger in the intake room. The bot must be
invited to + joined in this room (`settings.query_room()`, read once at startup). No poll loop and
no power level needed (read-only). Needs the server side in the s9pk (`POST /api/query/nl`, ≥ the
W2 backend) and the bot's CRM user set to role `bot`.
- **Bot CRM user needs role `bot`.** The email-proposal endpoints (`/api/intake/email-proposals*`)
are gated to `require_bot_or_admin` because they expose LP email content (the proposals are
admin-only on the web). The `bot` role is **authenticated-but-not-admin** — it passes these
endpoints + the auth-only ones the bot already uses (login, `/api/intake/match`,
`log-communication`), but **never** `require_admin` (no user-management/settings/security reach).
One-time flip of the existing service account (kept out of the invite UI's member/admin dropdown
— provision deliberately): an admin `PATCH /api/users/<id> {"role":"bot"}`, or on the box
`UPDATE users SET role='bot' WHERE username='<CRM_BOT_USERNAME>';`. Role controls *reach*; the
draft→approve gate (a human still approves every write) controls *autonomy* — two separate axes.
- **`INTAKE_TEAM_ROSTER`** (optional, comma-separated) — Ten31 team-member names that frame the
parse (see *Flow* step 1). Use the **first names as actually typed in the room** ("Grant,
Jonathan, …"). Read once at startup by `settings.team_roster()`, so **a roster change needs a
bot restart**. It lives only in the Spark's `.env` (bot-side) — no s9pk change. Empty/unset
disables the framing.
+87
View File
@@ -0,0 +1,87 @@
---
paths:
- backend/nl_query/**
---
# Natural-language query (W2)
Read this before editing the NL-query surface (`backend/nl_query/`). It is the read-only
"ask the database in plain English" layer — web "Ask" box + Matrix `@bot <question>`.
## The trust model — named intents, not a query language
There is **no generic SQL/AST compiler and no dynamically-built identifiers.** Every query is
a fixed, hand-written, reviewed, parameterized statement in `intents.py`; the only thing a
caller (or the model) controls is a small set of typed **slot values**, bound as `?` params.
`runner.validate` is the trust boundary: it accepts only a known intent key and coerces each
slot to its declared type, rejecting anything off-spec. A request that's wrong is rejected;
it can never name a table/column, pick an operator, or write SQL. `run_query` never raises —
every failure returns a structured error dict (a bad `limit=abc` must not crash the thread).
To add a capability: add a `run_*` + a registry entry (with its `slots` spec) in `intents.py`;
the translator prompt and the UI pick it up automatically from `catalog()`. Add a test case.
## Local-only — no Claude, no redaction here
Translation (question → `{intent, slots}`) runs on the **local Qwen via Spark Control**
(`translate.py`, reusing `ingest/llm.py`), the same sanctioned local leg as intake/digest. The
question never leaves the box, so there is **no Claude path and no redaction boundary** — that
was the whole point of the W2 simplification (the *answer* is sensitive and never leaves; the
*question* is generic English, translated locally). Validated **12/12** on real example
questions against the live Spark (2026-06-18). The model output is still untrusted: it goes
straight through `runner.validate`, so a hallucinated intent is rejected. If the local model
ever proves too weak, a Claude-behind-redaction translator could drop in as an alternative
`chat_fn` without touching the validator/executor — deliberately **not** built.
**Results never go to any model.** Summaries are deterministic local strings; rows render
client-side. Never add a "summarize these rows with an LLM" step — that re-introduces the leak.
## Soft-delete per table (the gotcha the design reviews caught)
The `fundraising_*` tables are a **hard-rebuilt projection** of the grid blob and have **no
`deleted_at` column** — do NOT add `deleted_at IS NULL` to them (it raises). Their live/retired
axis is the **`graveyard` flag** (exclude `graveyard = 1` for "live"). Other tables:
- `reminders` / `opportunities` / `communications` → filter `deleted_at IS NULL`.
- `emails` have no `deleted_at`; "live" = a non-tombstoned sighting (`EXISTS email_account_messages … deleted_at IS NULL`), mirroring `query_email_activity` / the digest.
`intents._last_activity_by_investor` **mirrors** `server.last_activity_by_investor` (duplicated
to avoid importing the `__main__` server module — helpers take a `conn`, never import server).
Keep the two in sync; the soft-delete test guards the copy.
## Email/comms intents are MATCHED-ONLY
The email-touching intents (`recent_emails`, `comms_by_user`, `email_counts_by_user`,
`investor_last_contact`) surface only **investor-linked** email — an `email_investor_links` row
must exist — exactly like the Communications panel's `query_email_activity`. Captured
internal/vendor/personal mail is never counted or listed. The gate is
`EXISTS (SELECT 1 FROM email_investor_links l WHERE l.email_id = e.id)`. **`comms_by_user` /
`email_counts_by_user` originally omitted this** and counted the user's *entire* sent corpus —
fixed; the runner test now seeds an unmatched sent email to guard it. Add this gate to any new
email intent.
## Endpoint, caps, audit
- `POST /api/query/nl` (`require_bot_or_admin`, read-only) — body `{question}` (local translate)
or `{intent, slots}` (direct, e.g. a UI re-run). Returns `{intent, slots, rows, summary,
question}`. `GET /api/query/catalog` returns the askable surface for the UI.
- **Clients (thin):** the **Matrix Q&A** surface is built — it lives bot-side in
`backend/matrix_intake/query.py` (trigger grammar + deterministic answer rendering) +
`crm_client.nl_query`, and ships on the Spark (no s9pk for the bot). Two entry points: a
**dedicated Q&A room** (`MATRIX_QUERY_ROOM`, every message is a question) and the `?`/`@bot`
trigger in the intake room. **It depends on this endpoint being live on the box** — which lands
with the v93 s9pk (reminders + W2); deploy the bot only after that, or it 404s. See the
matrix-intake guide. The **web "Ask" box** (Communications tab) is the remaining client.
- Status: local-model outage → **503**; unexpected SQL fault → **500**; everything else
(a hit, or a soft `no_match`/`unknown_intent`) → **200** with the structured result, because
the UI always wants the interpreted query back, not a bare code.
- Every executed query writes an audit row (`audit_log`, `entity_type='nl_query'`) so a query
through a leaked/automated credential is detectable. Global row ceiling `MAX_ROWS=500`.
## Tests + dev harness
`test_nl_query.py` (runner: every intent + soft-delete on both recency legs + injection-safety
+ caps), `test_translate.py` (offline translator via an injected `chat_fn`), and
`test_nl_query_endpoint.py` (HTTP auth/wiring/503, local model forced down via a dead
`SPARK_CONTROL_URL` port). `try_questions.py` is a dev harness (not a test) that fires
questions at the real local model and prints the translation — the cheap way to check quality.
+12
View File
@@ -21,6 +21,18 @@ Start9 0.4.x ignores a same-version rebuild (the install silently does nothing).
cd start9/0.4 && make # -> ten-database_x86_64.s9pk cd start9/0.4 && make # -> ten-database_x86_64.s9pk
``` ```
- The default `make` goal is `verified-build`: it runs the **frontend render smoke check**
(`start9/0.4/render-smoke.mjs`, via jsdom) *before* packing, so a build that can't render
fails fast. Run it standalone with `make render-smoke` (or `node render-smoke.mjs`). It
(1) transforms the app's inline JSX with the **shipped** Babel and asserts a classic,
non-module, parseable script — catching the v79 Babel-8 ESM-import regression — and
(2) mounts the app in jsdom and asserts the login UI renders — catching the v78 blank
screen. `jsdom` is a build-time devDependency (not shipped in the image); `npm ci` pulls
it. **Front-end libs are vendored + SRI-pinned** in `frontend/assets/vendor/` (React,
ReactDOM, Babel) and served same-origin — never re-point them at a CDN. If you re-vendor,
regenerate each `integrity="sha384-…"` in `frontend/index.html` with
`openssl dgst -sha384 -binary FILE | openssl base64 -A`.
## Install — PRODUCTION ## Install — PRODUCTION
```bash ```bash
@@ -0,0 +1,65 @@
# Handoff: add the Matrix intake bot as a spark-control dashboard card
**Do this work in the `spark-control` repo (`~/Projects/spark-control`), in a separate session.**
This repo (ten31-database) only owns the bot + its container; the dashboard card is driven
entirely by spark-control code. Prereq (DONE 2026-06-17): the bot already runs as a docker
container named **`matrix-intake`** on the Spark (`spark-32d0`, user `modelo`), via
`docker-compose.yml` at this repo's root. spark-control reaches it over the **same SSH channel it
already uses for `matrix-bridge`** (`modelo@spark-32d0`) — no new key/host needed.
The card is a near-exact clone of the existing `matrix-bridge` card. Mirror that, with **three
deltas** (below). File paths/line numbers are from the 2026-06-17 review; reconfirm against the
current code.
## Deltas from matrix-bridge (do NOT copy these blindly)
1. **Branch is `main`, not `master`.** The Update button runs `git reset --hard origin/<branch>`
— it MUST be `main` for ten31-database, or Update silently resets to the wrong/empty ref.
2. **Project dir is `/home/modelo/ten31-database`** (the CRM monorepo clone), not `~/matrix-intake`.
3. **Coupling caveat:** because the bot lives in the CRM monorepo, the Update one-liner does
`git reset --hard origin/main` on the **whole CRM clone**. Safe today (`.env` is gitignored,
the clone has no needed local edits), but this is exactly the blast-radius smell that motivates
eventually extracting the bot to its own repo (logged in ten31-database `ROADMAP.md`). If that
extraction happens first, point dir/branch/remote at the new repo instead.
## Edits in spark-control (mirror the matrix-bridge wiring)
1. **`image/app/config.py`** (matrix-bridge entry ~lines 99111): add `matrix_intake_host`
(default `spark2_host`), `matrix_intake_user`, `matrix_intake_container` (default
`"matrix-intake"`), `matrix_intake_dir` (default `"/home/modelo/ten31-database"`),
`matrix_intake_branch` (default **`"main"`**), each with a `MATRIX_INTAKE_*` env fallback.
2. **`image/app/services.py`** (matrix-bridge ServiceDef ~lines 95102): add a
`"matrix-intake": ServiceDef(name="matrix-intake", kind="bot", host=…, user=…,
container=…, port=0)` entry. `port=0` → judged by docker state alone (no HTTP probe), same as
matrix-bridge.
3. **`image/app/matrix_intake.py`** (new): copy `matrix_bridge.py`, rename
`matrix_bridge``matrix_intake` throughout. The Update command (`build_update_command`) must
produce: `cd /home/modelo/ten31-database && git fetch origin && git reset --hard origin/main &&
docker compose up -d --build`.
4. **`image/app/server.py`**: (a) add `"matrix-intake"` to the `service_action` whitelist
(~line 621); (b) `from .matrix_intake import MatrixIntakeManager` + instantiate
`matrix_intake = MatrixIntakeManager(settings)` (~line 47); (c) add the 4 endpoints mirroring
matrix-bridge: `POST /api/matrix-intake/update`, `GET …/update/{job_id}`,
`GET …/update/{job_id}/stream`, `GET /api/matrix-intake/logs`.
5. **`image/app/static/app.js`** — THE RISKY EDIT. The Update/View-logs handlers are hardcoded to
matrix-bridge (`data-mb-update`, `onMatrixBridgeUpdate`, `/api/matrix-bridge/...`). Generalize
them to dispatch by the card's bot name (e.g. read `name` off the card, call
`/api/<name>/update` and `/api/<name>/logs`). Start/Stop/Restart are already generic. **Regression-check
that the existing matrix-bridge card still updates + tails logs after this change.**
6. **`package/startos/fileModels/sparkConfig.yaml.ts`** (~lines 2732): add
`matrix_intake_user: z.string().catch('')`.
7. **`package/startos/main.ts`** (~line 68): inject `MATRIX_INTAKE_USER: cfg.matrix_intake_user`.
8. **(optional) `package/startos/actions/configureSparks.ts`**: add the intake-bot SSH-user field
to the form.
## Deploy + ops
- spark-control is itself an s9pk: **bump its version, rebuild, reinstall** per spark-control's own
packaging docs (don't forget — same "0.4.x ignores same-version" rule).
- One-time: run **Configure Sparks** → set the intake bot's SSH user to `modelo` (same as
matrix-bridge → key already authorized). The card appears once the `matrix-intake` container
exists and the user is set; it hides itself if the container is absent or the user is blank.
- Status pill = `docker inspect matrix-intake .State.Status` (running→Healthy). No Matrix-liveness
check — a running-but-silent bot still shows Healthy (same limitation as matrix-bridge).
## Done when
The dashboard shows a `matrix-intake` card alongside `matrix-bridge` with a Healthy pill and
working Update / Start / Restart / Stop / View-logs buttons — and the matrix-bridge card is
unregressed.
File diff suppressed because one or more lines are too long
+31
View File
@@ -0,0 +1,31 @@
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
(function(){'use strict';(function(c,x){"object"===typeof exports&&"undefined"!==typeof module?x(exports):"function"===typeof define&&define.amd?define(["exports"],x):(c=c||self,x(c.React={}))})(this,function(c){function x(a){if(null===a||"object"!==typeof a)return null;a=V&&a[V]||a["@@iterator"];return"function"===typeof a?a:null}function w(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Y(){}function K(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Z(a,b,
e){var m,d={},c=null,h=null;if(null!=b)for(m in void 0!==b.ref&&(h=b.ref),void 0!==b.key&&(c=""+b.key),b)aa.call(b,m)&&!ba.hasOwnProperty(m)&&(d[m]=b[m]);var l=arguments.length-2;if(1===l)d.children=e;else if(1<l){for(var f=Array(l),k=0;k<l;k++)f[k]=arguments[k+2];d.children=f}if(a&&a.defaultProps)for(m in l=a.defaultProps,l)void 0===d[m]&&(d[m]=l[m]);return{$$typeof:y,type:a,key:c,ref:h,props:d,_owner:L.current}}function oa(a,b){return{$$typeof:y,type:a.type,key:b,ref:a.ref,props:a.props,_owner:a._owner}}
function M(a){return"object"===typeof a&&null!==a&&a.$$typeof===y}function pa(a){var b={"=":"=0",":":"=2"};return"$"+a.replace(/[=:]/g,function(a){return b[a]})}function N(a,b){return"object"===typeof a&&null!==a&&null!=a.key?pa(""+a.key):b.toString(36)}function B(a,b,e,m,d){var c=typeof a;if("undefined"===c||"boolean"===c)a=null;var h=!1;if(null===a)h=!0;else switch(c){case "string":case "number":h=!0;break;case "object":switch(a.$$typeof){case y:case qa:h=!0}}if(h)return h=a,d=d(h),a=""===m?"."+
N(h,0):m,ca(d)?(e="",null!=a&&(e=a.replace(da,"$&/")+"/"),B(d,b,e,"",function(a){return a})):null!=d&&(M(d)&&(d=oa(d,e+(!d.key||h&&h.key===d.key?"":(""+d.key).replace(da,"$&/")+"/")+a)),b.push(d)),1;h=0;m=""===m?".":m+":";if(ca(a))for(var l=0;l<a.length;l++){c=a[l];var f=m+N(c,l);h+=B(c,b,e,f,d)}else if(f=x(a),"function"===typeof f)for(a=f.call(a),l=0;!(c=a.next()).done;)c=c.value,f=m+N(c,l++),h+=B(c,b,e,f,d);else if("object"===c)throw b=String(a),Error("Objects are not valid as a React child (found: "+
("[object Object]"===b?"object with keys {"+Object.keys(a).join(", ")+"}":b)+"). If you meant to render a collection of children, use an array instead.");return h}function C(a,b,e){if(null==a)return a;var c=[],d=0;B(a,c,"","",function(a){return b.call(e,a,d++)});return c}function ra(a){if(-1===a._status){var b=a._result;b=b();b.then(function(b){if(0===a._status||-1===a._status)a._status=1,a._result=b},function(b){if(0===a._status||-1===a._status)a._status=2,a._result=b});-1===a._status&&(a._status=
0,a._result=b)}if(1===a._status)return a._result.default;throw a._result;}function O(a,b){var e=a.length;a.push(b);a:for(;0<e;){var c=e-1>>>1,d=a[c];if(0<D(d,b))a[c]=b,a[e]=d,e=c;else break a}}function p(a){return 0===a.length?null:a[0]}function E(a){if(0===a.length)return null;var b=a[0],e=a.pop();if(e!==b){a[0]=e;a:for(var c=0,d=a.length,k=d>>>1;c<k;){var h=2*(c+1)-1,l=a[h],f=h+1,g=a[f];if(0>D(l,e))f<d&&0>D(g,l)?(a[c]=g,a[f]=e,c=f):(a[c]=l,a[h]=e,c=h);else if(f<d&&0>D(g,e))a[c]=g,a[f]=e,c=f;else break a}}return b}
function D(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}function P(a){for(var b=p(r);null!==b;){if(null===b.callback)E(r);else if(b.startTime<=a)E(r),b.sortIndex=b.expirationTime,O(q,b);else break;b=p(r)}}function Q(a){z=!1;P(a);if(!u)if(null!==p(q))u=!0,R(S);else{var b=p(r);null!==b&&T(Q,b.startTime-a)}}function S(a,b){u=!1;z&&(z=!1,ea(A),A=-1);F=!0;var c=k;try{P(b);for(n=p(q);null!==n&&(!(n.expirationTime>b)||a&&!fa());){var m=n.callback;if("function"===typeof m){n.callback=null;
k=n.priorityLevel;var d=m(n.expirationTime<=b);b=v();"function"===typeof d?n.callback=d:n===p(q)&&E(q);P(b)}else E(q);n=p(q)}if(null!==n)var g=!0;else{var h=p(r);null!==h&&T(Q,h.startTime-b);g=!1}return g}finally{n=null,k=c,F=!1}}function fa(){return v()-ha<ia?!1:!0}function R(a){G=a;H||(H=!0,I())}function T(a,b){A=ja(function(){a(v())},b)}function ka(a){throw Error("act(...) is not supported in production builds of React.");}var y=Symbol.for("react.element"),qa=Symbol.for("react.portal"),sa=Symbol.for("react.fragment"),
ta=Symbol.for("react.strict_mode"),ua=Symbol.for("react.profiler"),va=Symbol.for("react.provider"),wa=Symbol.for("react.context"),xa=Symbol.for("react.forward_ref"),ya=Symbol.for("react.suspense"),za=Symbol.for("react.memo"),Aa=Symbol.for("react.lazy"),V=Symbol.iterator,X={isMounted:function(a){return!1},enqueueForceUpdate:function(a,b,c){},enqueueReplaceState:function(a,b,c,m){},enqueueSetState:function(a,b,c,m){}},la=Object.assign,W={};w.prototype.isReactComponent={};w.prototype.setState=function(a,
b){if("object"!==typeof a&&"function"!==typeof a&&null!=a)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,a,b,"setState")};w.prototype.forceUpdate=function(a){this.updater.enqueueForceUpdate(this,a,"forceUpdate")};Y.prototype=w.prototype;var t=K.prototype=new Y;t.constructor=K;la(t,w.prototype);t.isPureReactComponent=!0;var ca=Array.isArray,aa=Object.prototype.hasOwnProperty,L={current:null},
ba={key:!0,ref:!0,__self:!0,__source:!0},da=/\/+/g,g={current:null},J={transition:null};if("object"===typeof performance&&"function"===typeof performance.now){var Ba=performance;var v=function(){return Ba.now()}}else{var ma=Date,Ca=ma.now();v=function(){return ma.now()-Ca}}var q=[],r=[],Da=1,n=null,k=3,F=!1,u=!1,z=!1,ja="function"===typeof setTimeout?setTimeout:null,ea="function"===typeof clearTimeout?clearTimeout:null,na="undefined"!==typeof setImmediate?setImmediate:null;"undefined"!==typeof navigator&&
void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);var H=!1,G=null,A=-1,ia=5,ha=-1,U=function(){if(null!==G){var a=v();ha=a;var b=!0;try{b=G(!0,a)}finally{b?I():(H=!1,G=null)}}else H=!1};if("function"===typeof na)var I=function(){na(U)};else if("undefined"!==typeof MessageChannel){t=new MessageChannel;var Ea=t.port2;t.port1.onmessage=U;I=function(){Ea.postMessage(null)}}else I=function(){ja(U,0)};t={ReactCurrentDispatcher:g,
ReactCurrentOwner:L,ReactCurrentBatchConfig:J,Scheduler:{__proto__:null,unstable_ImmediatePriority:1,unstable_UserBlockingPriority:2,unstable_NormalPriority:3,unstable_IdlePriority:5,unstable_LowPriority:4,unstable_runWithPriority:function(a,b){switch(a){case 1:case 2:case 3:case 4:case 5:break;default:a=3}var c=k;k=a;try{return b()}finally{k=c}},unstable_next:function(a){switch(k){case 1:case 2:case 3:var b=3;break;default:b=k}var c=k;k=b;try{return a()}finally{k=c}},unstable_scheduleCallback:function(a,
b,c){var e=v();"object"===typeof c&&null!==c?(c=c.delay,c="number"===typeof c&&0<c?e+c:e):c=e;switch(a){case 1:var d=-1;break;case 2:d=250;break;case 5:d=1073741823;break;case 4:d=1E4;break;default:d=5E3}d=c+d;a={id:Da++,callback:b,priorityLevel:a,startTime:c,expirationTime:d,sortIndex:-1};c>e?(a.sortIndex=c,O(r,a),null===p(q)&&a===p(r)&&(z?(ea(A),A=-1):z=!0,T(Q,c-e))):(a.sortIndex=d,O(q,a),u||F||(u=!0,R(S)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b=
k;return function(){var c=k;k=b;try{return a.apply(this,arguments)}finally{k=c}}},unstable_getCurrentPriorityLevel:function(){return k},unstable_shouldYield:fa,unstable_requestPaint:function(){},unstable_continueExecution:function(){u||F||(u=!0,R(S))},unstable_pauseExecution:function(){},unstable_getFirstCallbackNode:function(){return p(q)},get unstable_now(){return v},unstable_forceFrameRate:function(a){0>a||125<a?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):
ia=0<a?Math.floor(1E3/a):5},unstable_Profiling:null}};c.Children={map:C,forEach:function(a,b,c){C(a,function(){b.apply(this,arguments)},c)},count:function(a){var b=0;C(a,function(){b++});return b},toArray:function(a){return C(a,function(a){return a})||[]},only:function(a){if(!M(a))throw Error("React.Children.only expected to receive a single React element child.");return a}};c.Component=w;c.Fragment=sa;c.Profiler=ua;c.PureComponent=K;c.StrictMode=ta;c.Suspense=ya;c.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=
t;c.act=ka;c.cloneElement=function(a,b,c){if(null===a||void 0===a)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+a+".");var e=la({},a.props),d=a.key,k=a.ref,h=a._owner;if(null!=b){void 0!==b.ref&&(k=b.ref,h=L.current);void 0!==b.key&&(d=""+b.key);if(a.type&&a.type.defaultProps)var l=a.type.defaultProps;for(f in b)aa.call(b,f)&&!ba.hasOwnProperty(f)&&(e[f]=void 0===b[f]&&void 0!==l?l[f]:b[f])}var f=arguments.length-2;if(1===f)e.children=c;else if(1<f){l=
Array(f);for(var g=0;g<f;g++)l[g]=arguments[g+2];e.children=l}return{$$typeof:y,type:a.type,key:d,ref:k,props:e,_owner:h}};c.createContext=function(a){a={$$typeof:wa,_currentValue:a,_currentValue2:a,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null};a.Provider={$$typeof:va,_context:a};return a.Consumer=a};c.createElement=Z;c.createFactory=function(a){var b=Z.bind(null,a);b.type=a;return b};c.createRef=function(){return{current:null}};c.forwardRef=function(a){return{$$typeof:xa,
render:a}};c.isValidElement=M;c.lazy=function(a){return{$$typeof:Aa,_payload:{_status:-1,_result:a},_init:ra}};c.memo=function(a,b){return{$$typeof:za,type:a,compare:void 0===b?null:b}};c.startTransition=function(a,b){b=J.transition;J.transition={};try{a()}finally{J.transition=b}};c.unstable_act=ka;c.useCallback=function(a,b){return g.current.useCallback(a,b)};c.useContext=function(a){return g.current.useContext(a)};c.useDebugValue=function(a,b){};c.useDeferredValue=function(a){return g.current.useDeferredValue(a)};
c.useEffect=function(a,b){return g.current.useEffect(a,b)};c.useId=function(){return g.current.useId()};c.useImperativeHandle=function(a,b,c){return g.current.useImperativeHandle(a,b,c)};c.useInsertionEffect=function(a,b){return g.current.useInsertionEffect(a,b)};c.useLayoutEffect=function(a,b){return g.current.useLayoutEffect(a,b)};c.useMemo=function(a,b){return g.current.useMemo(a,b)};c.useReducer=function(a,b,c){return g.current.useReducer(a,b,c)};c.useRef=function(a){return g.current.useRef(a)};
c.useState=function(a){return g.current.useState(a)};c.useSyncExternalStore=function(a,b,c){return g.current.useSyncExternalStore(a,b,c)};c.useTransition=function(){return g.current.useTransition()};c.version="18.3.1"});
})();
@@ -0,0 +1,267 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
(function(){/*
Modernizr 3.0.0pre (Custom Build) | MIT
*/
'use strict';(function(Q,zb){"object"===typeof exports&&"undefined"!==typeof module?zb(exports,require("react")):"function"===typeof define&&define.amd?define(["exports","react"],zb):(Q=Q||self,zb(Q.ReactDOM={},Q.React))})(this,function(Q,zb){function m(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,c=1;c<arguments.length;c++)b+="&args[]="+encodeURIComponent(arguments[c]);return"Minified React error #"+a+"; visit "+b+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}
function mb(a,b){Ab(a,b);Ab(a+"Capture",b)}function Ab(a,b){$b[a]=b;for(a=0;a<b.length;a++)cg.add(b[a])}function bj(a){if(Zd.call(dg,a))return!0;if(Zd.call(eg,a))return!1;if(cj.test(a))return dg[a]=!0;eg[a]=!0;return!1}function dj(a,b,c,d){if(null!==c&&0===c.type)return!1;switch(typeof b){case "function":case "symbol":return!0;case "boolean":if(d)return!1;if(null!==c)return!c.acceptsBooleans;a=a.toLowerCase().slice(0,5);return"data-"!==a&&"aria-"!==a;default:return!1}}function ej(a,b,c,d){if(null===
b||"undefined"===typeof b||dj(a,b,c,d))return!0;if(d)return!1;if(null!==c)switch(c.type){case 3:return!b;case 4:return!1===b;case 5:return isNaN(b);case 6:return isNaN(b)||1>b}return!1}function Y(a,b,c,d,e,f,g){this.acceptsBooleans=2===b||3===b||4===b;this.attributeName=d;this.attributeNamespace=e;this.mustUseProperty=c;this.propertyName=a;this.type=b;this.sanitizeURL=f;this.removeEmptyString=g}function $d(a,b,c,d){var e=R.hasOwnProperty(b)?R[b]:null;if(null!==e?0!==e.type:d||!(2<b.length)||"o"!==
b[0]&&"O"!==b[0]||"n"!==b[1]&&"N"!==b[1])ej(b,c,e,d)&&(c=null),d||null===e?bj(b)&&(null===c?a.removeAttribute(b):a.setAttribute(b,""+c)):e.mustUseProperty?a[e.propertyName]=null===c?3===e.type?!1:"":c:(b=e.attributeName,d=e.attributeNamespace,null===c?a.removeAttribute(b):(e=e.type,c=3===e||4===e&&!0===c?"":""+c,d?a.setAttributeNS(d,b,c):a.setAttribute(b,c)))}function ac(a){if(null===a||"object"!==typeof a)return null;a=fg&&a[fg]||a["@@iterator"];return"function"===typeof a?a:null}function bc(a,b,
c){if(void 0===ae)try{throw Error();}catch(d){ae=(b=d.stack.trim().match(/\n( *(at )?)/))&&b[1]||""}return"\n"+ae+a}function be(a,b){if(!a||ce)return"";ce=!0;var c=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(b)if(b=function(){throw Error();},Object.defineProperty(b.prototype,"props",{set:function(){throw Error();}}),"object"===typeof Reflect&&Reflect.construct){try{Reflect.construct(b,[])}catch(n){var d=n}Reflect.construct(a,[],b)}else{try{b.call()}catch(n){d=n}a.call(b.prototype)}else{try{throw Error();
}catch(n){d=n}a()}}catch(n){if(n&&d&&"string"===typeof n.stack){for(var e=n.stack.split("\n"),f=d.stack.split("\n"),g=e.length-1,h=f.length-1;1<=g&&0<=h&&e[g]!==f[h];)h--;for(;1<=g&&0<=h;g--,h--)if(e[g]!==f[h]){if(1!==g||1!==h){do if(g--,h--,0>h||e[g]!==f[h]){var k="\n"+e[g].replace(" at new "," at ");a.displayName&&k.includes("<anonymous>")&&(k=k.replace("<anonymous>",a.displayName));return k}while(1<=g&&0<=h)}break}}}finally{ce=!1,Error.prepareStackTrace=c}return(a=a?a.displayName||a.name:"")?bc(a):
""}function fj(a){switch(a.tag){case 5:return bc(a.type);case 16:return bc("Lazy");case 13:return bc("Suspense");case 19:return bc("SuspenseList");case 0:case 2:case 15:return a=be(a.type,!1),a;case 11:return a=be(a.type.render,!1),a;case 1:return a=be(a.type,!0),a;default:return""}}function de(a){if(null==a)return null;if("function"===typeof a)return a.displayName||a.name||null;if("string"===typeof a)return a;switch(a){case Bb:return"Fragment";case Cb:return"Portal";case ee:return"Profiler";case fe:return"StrictMode";
case ge:return"Suspense";case he:return"SuspenseList"}if("object"===typeof a)switch(a.$$typeof){case gg:return(a.displayName||"Context")+".Consumer";case hg:return(a._context.displayName||"Context")+".Provider";case ie:var b=a.render;a=a.displayName;a||(a=b.displayName||b.name||"",a=""!==a?"ForwardRef("+a+")":"ForwardRef");return a;case je:return b=a.displayName||null,null!==b?b:de(a.type)||"Memo";case Ta:b=a._payload;a=a._init;try{return de(a(b))}catch(c){}}return null}function gj(a){var b=a.type;
switch(a.tag){case 24:return"Cache";case 9:return(b.displayName||"Context")+".Consumer";case 10:return(b._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return a=b.render,a=a.displayName||a.name||"",b.displayName||(""!==a?"ForwardRef("+a+")":"ForwardRef");case 7:return"Fragment";case 5:return b;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return de(b);case 8:return b===fe?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";
case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if("function"===typeof b)return b.displayName||b.name||null;if("string"===typeof b)return b}return null}function Ua(a){switch(typeof a){case "boolean":case "number":case "string":case "undefined":return a;case "object":return a;default:return""}}function ig(a){var b=a.type;return(a=a.nodeName)&&"input"===a.toLowerCase()&&("checkbox"===b||"radio"===
b)}function hj(a){var b=ig(a)?"checked":"value",c=Object.getOwnPropertyDescriptor(a.constructor.prototype,b),d=""+a[b];if(!a.hasOwnProperty(b)&&"undefined"!==typeof c&&"function"===typeof c.get&&"function"===typeof c.set){var e=c.get,f=c.set;Object.defineProperty(a,b,{configurable:!0,get:function(){return e.call(this)},set:function(a){d=""+a;f.call(this,a)}});Object.defineProperty(a,b,{enumerable:c.enumerable});return{getValue:function(){return d},setValue:function(a){d=""+a},stopTracking:function(){a._valueTracker=
null;delete a[b]}}}}function Pc(a){a._valueTracker||(a._valueTracker=hj(a))}function jg(a){if(!a)return!1;var b=a._valueTracker;if(!b)return!0;var c=b.getValue();var d="";a&&(d=ig(a)?a.checked?"true":"false":a.value);a=d;return a!==c?(b.setValue(a),!0):!1}function Qc(a){a=a||("undefined"!==typeof document?document:void 0);if("undefined"===typeof a)return null;try{return a.activeElement||a.body}catch(b){return a.body}}function ke(a,b){var c=b.checked;return E({},b,{defaultChecked:void 0,defaultValue:void 0,
value:void 0,checked:null!=c?c:a._wrapperState.initialChecked})}function kg(a,b){var c=null==b.defaultValue?"":b.defaultValue,d=null!=b.checked?b.checked:b.defaultChecked;c=Ua(null!=b.value?b.value:c);a._wrapperState={initialChecked:d,initialValue:c,controlled:"checkbox"===b.type||"radio"===b.type?null!=b.checked:null!=b.value}}function lg(a,b){b=b.checked;null!=b&&$d(a,"checked",b,!1)}function le(a,b){lg(a,b);var c=Ua(b.value),d=b.type;if(null!=c)if("number"===d){if(0===c&&""===a.value||a.value!=
c)a.value=""+c}else a.value!==""+c&&(a.value=""+c);else if("submit"===d||"reset"===d){a.removeAttribute("value");return}b.hasOwnProperty("value")?me(a,b.type,c):b.hasOwnProperty("defaultValue")&&me(a,b.type,Ua(b.defaultValue));null==b.checked&&null!=b.defaultChecked&&(a.defaultChecked=!!b.defaultChecked)}function mg(a,b,c){if(b.hasOwnProperty("value")||b.hasOwnProperty("defaultValue")){var d=b.type;if(!("submit"!==d&&"reset"!==d||void 0!==b.value&&null!==b.value))return;b=""+a._wrapperState.initialValue;
c||b===a.value||(a.value=b);a.defaultValue=b}c=a.name;""!==c&&(a.name="");a.defaultChecked=!!a._wrapperState.initialChecked;""!==c&&(a.name=c)}function me(a,b,c){if("number"!==b||Qc(a.ownerDocument)!==a)null==c?a.defaultValue=""+a._wrapperState.initialValue:a.defaultValue!==""+c&&(a.defaultValue=""+c)}function Db(a,b,c,d){a=a.options;if(b){b={};for(var e=0;e<c.length;e++)b["$"+c[e]]=!0;for(c=0;c<a.length;c++)e=b.hasOwnProperty("$"+a[c].value),a[c].selected!==e&&(a[c].selected=e),e&&d&&(a[c].defaultSelected=
!0)}else{c=""+Ua(c);b=null;for(e=0;e<a.length;e++){if(a[e].value===c){a[e].selected=!0;d&&(a[e].defaultSelected=!0);return}null!==b||a[e].disabled||(b=a[e])}null!==b&&(b.selected=!0)}}function ne(a,b){if(null!=b.dangerouslySetInnerHTML)throw Error(m(91));return E({},b,{value:void 0,defaultValue:void 0,children:""+a._wrapperState.initialValue})}function ng(a,b){var c=b.value;if(null==c){c=b.children;b=b.defaultValue;if(null!=c){if(null!=b)throw Error(m(92));if(cc(c)){if(1<c.length)throw Error(m(93));
c=c[0]}b=c}null==b&&(b="");c=b}a._wrapperState={initialValue:Ua(c)}}function og(a,b){var c=Ua(b.value),d=Ua(b.defaultValue);null!=c&&(c=""+c,c!==a.value&&(a.value=c),null==b.defaultValue&&a.defaultValue!==c&&(a.defaultValue=c));null!=d&&(a.defaultValue=""+d)}function pg(a,b){b=a.textContent;b===a._wrapperState.initialValue&&""!==b&&null!==b&&(a.value=b)}function qg(a){switch(a){case "svg":return"http://www.w3.org/2000/svg";case "math":return"http://www.w3.org/1998/Math/MathML";default:return"http://www.w3.org/1999/xhtml"}}
function oe(a,b){return null==a||"http://www.w3.org/1999/xhtml"===a?qg(b):"http://www.w3.org/2000/svg"===a&&"foreignObject"===b?"http://www.w3.org/1999/xhtml":a}function rg(a,b,c){return null==b||"boolean"===typeof b||""===b?"":c||"number"!==typeof b||0===b||dc.hasOwnProperty(a)&&dc[a]?(""+b).trim():b+"px"}function sg(a,b){a=a.style;for(var c in b)if(b.hasOwnProperty(c)){var d=0===c.indexOf("--"),e=rg(c,b[c],d);"float"===c&&(c="cssFloat");d?a.setProperty(c,e):a[c]=e}}function pe(a,b){if(b){if(ij[a]&&
(null!=b.children||null!=b.dangerouslySetInnerHTML))throw Error(m(137,a));if(null!=b.dangerouslySetInnerHTML){if(null!=b.children)throw Error(m(60));if("object"!==typeof b.dangerouslySetInnerHTML||!("__html"in b.dangerouslySetInnerHTML))throw Error(m(61));}if(null!=b.style&&"object"!==typeof b.style)throw Error(m(62));}}function qe(a,b){if(-1===a.indexOf("-"))return"string"===typeof b.is;switch(a){case "annotation-xml":case "color-profile":case "font-face":case "font-face-src":case "font-face-uri":case "font-face-format":case "font-face-name":case "missing-glyph":return!1;
default:return!0}}function re(a){a=a.target||a.srcElement||window;a.correspondingUseElement&&(a=a.correspondingUseElement);return 3===a.nodeType?a.parentNode:a}function tg(a){if(a=ec(a)){if("function"!==typeof se)throw Error(m(280));var b=a.stateNode;b&&(b=Rc(b),se(a.stateNode,a.type,b))}}function ug(a){Eb?Fb?Fb.push(a):Fb=[a]:Eb=a}function vg(){if(Eb){var a=Eb,b=Fb;Fb=Eb=null;tg(a);if(b)for(a=0;a<b.length;a++)tg(b[a])}}function wg(a,b,c){if(te)return a(b,c);te=!0;try{return xg(a,b,c)}finally{if(te=
!1,null!==Eb||null!==Fb)yg(),vg()}}function fc(a,b){var c=a.stateNode;if(null===c)return null;var d=Rc(c);if(null===d)return null;c=d[b];a:switch(b){case "onClick":case "onClickCapture":case "onDoubleClick":case "onDoubleClickCapture":case "onMouseDown":case "onMouseDownCapture":case "onMouseMove":case "onMouseMoveCapture":case "onMouseUp":case "onMouseUpCapture":case "onMouseEnter":(d=!d.disabled)||(a=a.type,d=!("button"===a||"input"===a||"select"===a||"textarea"===a));a=!d;break a;default:a=!1}if(a)return null;
if(c&&"function"!==typeof c)throw Error(m(231,b,typeof c));return c}function jj(a,b,c,d,e,f,g,h,k){gc=!1;Sc=null;kj.apply(lj,arguments)}function mj(a,b,c,d,e,f,g,h,k){jj.apply(this,arguments);if(gc){if(gc){var n=Sc;gc=!1;Sc=null}else throw Error(m(198));Tc||(Tc=!0,ue=n)}}function nb(a){var b=a,c=a;if(a.alternate)for(;b.return;)b=b.return;else{a=b;do b=a,0!==(b.flags&4098)&&(c=b.return),a=b.return;while(a)}return 3===b.tag?c:null}function zg(a){if(13===a.tag){var b=a.memoizedState;null===b&&(a=a.alternate,
null!==a&&(b=a.memoizedState));if(null!==b)return b.dehydrated}return null}function Ag(a){if(nb(a)!==a)throw Error(m(188));}function nj(a){var b=a.alternate;if(!b){b=nb(a);if(null===b)throw Error(m(188));return b!==a?null:a}for(var c=a,d=b;;){var e=c.return;if(null===e)break;var f=e.alternate;if(null===f){d=e.return;if(null!==d){c=d;continue}break}if(e.child===f.child){for(f=e.child;f;){if(f===c)return Ag(e),a;if(f===d)return Ag(e),b;f=f.sibling}throw Error(m(188));}if(c.return!==d.return)c=e,d=f;
else{for(var g=!1,h=e.child;h;){if(h===c){g=!0;c=e;d=f;break}if(h===d){g=!0;d=e;c=f;break}h=h.sibling}if(!g){for(h=f.child;h;){if(h===c){g=!0;c=f;d=e;break}if(h===d){g=!0;d=f;c=e;break}h=h.sibling}if(!g)throw Error(m(189));}}if(c.alternate!==d)throw Error(m(190));}if(3!==c.tag)throw Error(m(188));return c.stateNode.current===c?a:b}function Bg(a){a=nj(a);return null!==a?Cg(a):null}function Cg(a){if(5===a.tag||6===a.tag)return a;for(a=a.child;null!==a;){var b=Cg(a);if(null!==b)return b;a=a.sibling}return null}
function oj(a,b){if(Ca&&"function"===typeof Ca.onCommitFiberRoot)try{Ca.onCommitFiberRoot(Uc,a,void 0,128===(a.current.flags&128))}catch(c){}}function pj(a){a>>>=0;return 0===a?32:31-(qj(a)/rj|0)|0}function hc(a){switch(a&-a){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a&
4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return a&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return a}}function Vc(a,b){var c=a.pendingLanes;if(0===c)return 0;var d=0,e=a.suspendedLanes,f=a.pingedLanes,g=c&268435455;if(0!==g){var h=g&~e;0!==h?d=hc(h):(f&=g,0!==f&&(d=hc(f)))}else g=c&~e,0!==g?d=hc(g):0!==f&&(d=hc(f));if(0===d)return 0;if(0!==b&&b!==d&&0===(b&e)&&
(e=d&-d,f=b&-b,e>=f||16===e&&0!==(f&4194240)))return b;0!==(d&4)&&(d|=c&16);b=a.entangledLanes;if(0!==b)for(a=a.entanglements,b&=d;0<b;)c=31-ta(b),e=1<<c,d|=a[c],b&=~e;return d}function sj(a,b){switch(a){case 1:case 2:case 4:return b+250;case 8:case 16:case 32:case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return b+5E3;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return-1;
case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function tj(a,b){for(var c=a.suspendedLanes,d=a.pingedLanes,e=a.expirationTimes,f=a.pendingLanes;0<f;){var g=31-ta(f),h=1<<g,k=e[g];if(-1===k){if(0===(h&c)||0!==(h&d))e[g]=sj(h,b)}else k<=b&&(a.expiredLanes|=h);f&=~h}}function ve(a){a=a.pendingLanes&-1073741825;return 0!==a?a:a&1073741824?1073741824:0}function Dg(){var a=Wc;Wc<<=1;0===(Wc&4194240)&&(Wc=64);return a}function we(a){for(var b=[],c=0;31>c;c++)b.push(a);
return b}function ic(a,b,c){a.pendingLanes|=b;536870912!==b&&(a.suspendedLanes=0,a.pingedLanes=0);a=a.eventTimes;b=31-ta(b);a[b]=c}function uj(a,b){var c=a.pendingLanes&~b;a.pendingLanes=b;a.suspendedLanes=0;a.pingedLanes=0;a.expiredLanes&=b;a.mutableReadLanes&=b;a.entangledLanes&=b;b=a.entanglements;var d=a.eventTimes;for(a=a.expirationTimes;0<c;){var e=31-ta(c),f=1<<e;b[e]=0;d[e]=-1;a[e]=-1;c&=~f}}function xe(a,b){var c=a.entangledLanes|=b;for(a=a.entanglements;c;){var d=31-ta(c),e=1<<d;e&b|a[d]&
b&&(a[d]|=b);c&=~e}}function Eg(a){a&=-a;return 1<a?4<a?0!==(a&268435455)?16:536870912:4:1}function Fg(a,b){switch(a){case "focusin":case "focusout":Va=null;break;case "dragenter":case "dragleave":Wa=null;break;case "mouseover":case "mouseout":Xa=null;break;case "pointerover":case "pointerout":jc.delete(b.pointerId);break;case "gotpointercapture":case "lostpointercapture":kc.delete(b.pointerId)}}function lc(a,b,c,d,e,f){if(null===a||a.nativeEvent!==f)return a={blockedOn:b,domEventName:c,eventSystemFlags:d,
nativeEvent:f,targetContainers:[e]},null!==b&&(b=ec(b),null!==b&&Gg(b)),a;a.eventSystemFlags|=d;b=a.targetContainers;null!==e&&-1===b.indexOf(e)&&b.push(e);return a}function vj(a,b,c,d,e){switch(b){case "focusin":return Va=lc(Va,a,b,c,d,e),!0;case "dragenter":return Wa=lc(Wa,a,b,c,d,e),!0;case "mouseover":return Xa=lc(Xa,a,b,c,d,e),!0;case "pointerover":var f=e.pointerId;jc.set(f,lc(jc.get(f)||null,a,b,c,d,e));return!0;case "gotpointercapture":return f=e.pointerId,kc.set(f,lc(kc.get(f)||null,a,b,
c,d,e)),!0}return!1}function Hg(a){var b=ob(a.target);if(null!==b){var c=nb(b);if(null!==c)if(b=c.tag,13===b){if(b=zg(c),null!==b){a.blockedOn=b;wj(a.priority,function(){xj(c)});return}}else if(3===b&&c.stateNode.current.memoizedState.isDehydrated){a.blockedOn=3===c.tag?c.stateNode.containerInfo:null;return}}a.blockedOn=null}function Xc(a){if(null!==a.blockedOn)return!1;for(var b=a.targetContainers;0<b.length;){var c=ye(a.domEventName,a.eventSystemFlags,b[0],a.nativeEvent);if(null===c){c=a.nativeEvent;
var d=new c.constructor(c.type,c);ze=d;c.target.dispatchEvent(d);ze=null}else return b=ec(c),null!==b&&Gg(b),a.blockedOn=c,!1;b.shift()}return!0}function Ig(a,b,c){Xc(a)&&c.delete(b)}function yj(){Ae=!1;null!==Va&&Xc(Va)&&(Va=null);null!==Wa&&Xc(Wa)&&(Wa=null);null!==Xa&&Xc(Xa)&&(Xa=null);jc.forEach(Ig);kc.forEach(Ig)}function mc(a,b){a.blockedOn===b&&(a.blockedOn=null,Ae||(Ae=!0,Jg(Kg,yj)))}function nc(a){if(0<Yc.length){mc(Yc[0],a);for(var b=1;b<Yc.length;b++){var c=Yc[b];c.blockedOn===a&&(c.blockedOn=
null)}}null!==Va&&mc(Va,a);null!==Wa&&mc(Wa,a);null!==Xa&&mc(Xa,a);b=function(b){return mc(b,a)};jc.forEach(b);kc.forEach(b);for(b=0;b<Ya.length;b++)c=Ya[b],c.blockedOn===a&&(c.blockedOn=null);for(;0<Ya.length&&(b=Ya[0],null===b.blockedOn);)Hg(b),null===b.blockedOn&&Ya.shift()}function zj(a,b,c,d){var e=z,f=Gb.transition;Gb.transition=null;try{z=1,Be(a,b,c,d)}finally{z=e,Gb.transition=f}}function Aj(a,b,c,d){var e=z,f=Gb.transition;Gb.transition=null;try{z=4,Be(a,b,c,d)}finally{z=e,Gb.transition=
f}}function Be(a,b,c,d){if(Zc){var e=ye(a,b,c,d);if(null===e)Ce(a,b,d,$c,c),Fg(a,d);else if(vj(e,a,b,c,d))d.stopPropagation();else if(Fg(a,d),b&4&&-1<Bj.indexOf(a)){for(;null!==e;){var f=ec(e);null!==f&&Cj(f);f=ye(a,b,c,d);null===f&&Ce(a,b,d,$c,c);if(f===e)break;e=f}null!==e&&d.stopPropagation()}else Ce(a,b,d,null,c)}}function ye(a,b,c,d){$c=null;a=re(d);a=ob(a);if(null!==a)if(b=nb(a),null===b)a=null;else if(c=b.tag,13===c){a=zg(b);if(null!==a)return a;a=null}else if(3===c){if(b.stateNode.current.memoizedState.isDehydrated)return 3===
b.tag?b.stateNode.containerInfo:null;a=null}else b!==a&&(a=null);$c=a;return null}function Lg(a){switch(a){case "cancel":case "click":case "close":case "contextmenu":case "copy":case "cut":case "auxclick":case "dblclick":case "dragend":case "dragstart":case "drop":case "focusin":case "focusout":case "input":case "invalid":case "keydown":case "keypress":case "keyup":case "mousedown":case "mouseup":case "paste":case "pause":case "play":case "pointercancel":case "pointerdown":case "pointerup":case "ratechange":case "reset":case "resize":case "seeked":case "submit":case "touchcancel":case "touchend":case "touchstart":case "volumechange":case "change":case "selectionchange":case "textInput":case "compositionstart":case "compositionend":case "compositionupdate":case "beforeblur":case "afterblur":case "beforeinput":case "blur":case "fullscreenchange":case "focus":case "hashchange":case "popstate":case "select":case "selectstart":return 1;
case "drag":case "dragenter":case "dragexit":case "dragleave":case "dragover":case "mousemove":case "mouseout":case "mouseover":case "pointermove":case "pointerout":case "pointerover":case "scroll":case "toggle":case "touchmove":case "wheel":case "mouseenter":case "mouseleave":case "pointerenter":case "pointerleave":return 4;case "message":switch(Dj()){case De:return 1;case Mg:return 4;case ad:case Ej:return 16;case Ng:return 536870912;default:return 16}default:return 16}}function Og(){if(bd)return bd;
var a,b=Ee,c=b.length,d,e="value"in Za?Za.value:Za.textContent,f=e.length;for(a=0;a<c&&b[a]===e[a];a++);var g=c-a;for(d=1;d<=g&&b[c-d]===e[f-d];d++);return bd=e.slice(a,1<d?1-d:void 0)}function cd(a){var b=a.keyCode;"charCode"in a?(a=a.charCode,0===a&&13===b&&(a=13)):a=b;10===a&&(a=13);return 32<=a||13===a?a:0}function dd(){return!0}function Pg(){return!1}function ka(a){function b(b,d,e,f,g){this._reactName=b;this._targetInst=e;this.type=d;this.nativeEvent=f;this.target=g;this.currentTarget=null;
for(var c in a)a.hasOwnProperty(c)&&(b=a[c],this[c]=b?b(f):f[c]);this.isDefaultPrevented=(null!=f.defaultPrevented?f.defaultPrevented:!1===f.returnValue)?dd:Pg;this.isPropagationStopped=Pg;return this}E(b.prototype,{preventDefault:function(){this.defaultPrevented=!0;var a=this.nativeEvent;a&&(a.preventDefault?a.preventDefault():"unknown"!==typeof a.returnValue&&(a.returnValue=!1),this.isDefaultPrevented=dd)},stopPropagation:function(){var a=this.nativeEvent;a&&(a.stopPropagation?a.stopPropagation():
"unknown"!==typeof a.cancelBubble&&(a.cancelBubble=!0),this.isPropagationStopped=dd)},persist:function(){},isPersistent:dd});return b}function Fj(a){var b=this.nativeEvent;return b.getModifierState?b.getModifierState(a):(a=Gj[a])?!!b[a]:!1}function Fe(a){return Fj}function Qg(a,b){switch(a){case "keyup":return-1!==Hj.indexOf(b.keyCode);case "keydown":return 229!==b.keyCode;case "keypress":case "mousedown":case "focusout":return!0;default:return!1}}function Rg(a){a=a.detail;return"object"===typeof a&&
"data"in a?a.data:null}function Ij(a,b){switch(a){case "compositionend":return Rg(b);case "keypress":if(32!==b.which)return null;Sg=!0;return Tg;case "textInput":return a=b.data,a===Tg&&Sg?null:a;default:return null}}function Jj(a,b){if(Hb)return"compositionend"===a||!Ge&&Qg(a,b)?(a=Og(),bd=Ee=Za=null,Hb=!1,a):null;switch(a){case "paste":return null;case "keypress":if(!(b.ctrlKey||b.altKey||b.metaKey)||b.ctrlKey&&b.altKey){if(b.char&&1<b.char.length)return b.char;if(b.which)return String.fromCharCode(b.which)}return null;
case "compositionend":return Ug&&"ko"!==b.locale?null:b.data;default:return null}}function Vg(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return"input"===b?!!Kj[a.type]:"textarea"===b?!0:!1}function Lj(a){if(!Ia)return!1;a="on"+a;var b=a in document;b||(b=document.createElement("div"),b.setAttribute(a,"return;"),b="function"===typeof b[a]);return b}function Wg(a,b,c,d){ug(d);b=ed(b,"onChange");0<b.length&&(c=new He("onChange","change",null,c,d),a.push({event:c,listeners:b}))}function Mj(a){Xg(a,
0)}function fd(a){var b=Ib(a);if(jg(b))return a}function Nj(a,b){if("change"===a)return b}function Yg(){oc&&(oc.detachEvent("onpropertychange",Zg),pc=oc=null)}function Zg(a){if("value"===a.propertyName&&fd(pc)){var b=[];Wg(b,pc,a,re(a));wg(Mj,b)}}function Oj(a,b,c){"focusin"===a?(Yg(),oc=b,pc=c,oc.attachEvent("onpropertychange",Zg)):"focusout"===a&&Yg()}function Pj(a,b){if("selectionchange"===a||"keyup"===a||"keydown"===a)return fd(pc)}function Qj(a,b){if("click"===a)return fd(b)}function Rj(a,b){if("input"===
a||"change"===a)return fd(b)}function Sj(a,b){return a===b&&(0!==a||1/a===1/b)||a!==a&&b!==b}function qc(a,b){if(ua(a,b))return!0;if("object"!==typeof a||null===a||"object"!==typeof b||null===b)return!1;var c=Object.keys(a),d=Object.keys(b);if(c.length!==d.length)return!1;for(d=0;d<c.length;d++){var e=c[d];if(!Zd.call(b,e)||!ua(a[e],b[e]))return!1}return!0}function $g(a){for(;a&&a.firstChild;)a=a.firstChild;return a}function ah(a,b){var c=$g(a);a=0;for(var d;c;){if(3===c.nodeType){d=a+c.textContent.length;
if(a<=b&&d>=b)return{node:c,offset:b-a};a=d}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode}c=void 0}c=$g(c)}}function bh(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?bh(a,b.parentNode):"contains"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1}function ch(){for(var a=window,b=Qc();b instanceof a.HTMLIFrameElement;){try{var c="string"===typeof b.contentWindow.location.href}catch(d){c=!1}if(c)a=b.contentWindow;else break;
b=Qc(a.document)}return b}function Ie(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&("input"===b&&("text"===a.type||"search"===a.type||"tel"===a.type||"url"===a.type||"password"===a.type)||"textarea"===b||"true"===a.contentEditable)}function Tj(a){var b=ch(),c=a.focusedElem,d=a.selectionRange;if(b!==c&&c&&c.ownerDocument&&bh(c.ownerDocument.documentElement,c)){if(null!==d&&Ie(c))if(b=d.start,a=d.end,void 0===a&&(a=b),"selectionStart"in c)c.selectionStart=b,c.selectionEnd=Math.min(a,c.value.length);
else if(a=(b=c.ownerDocument||document)&&b.defaultView||window,a.getSelection){a=a.getSelection();var e=c.textContent.length,f=Math.min(d.start,e);d=void 0===d.end?f:Math.min(d.end,e);!a.extend&&f>d&&(e=d,d=f,f=e);e=ah(c,f);var g=ah(c,d);e&&g&&(1!==a.rangeCount||a.anchorNode!==e.node||a.anchorOffset!==e.offset||a.focusNode!==g.node||a.focusOffset!==g.offset)&&(b=b.createRange(),b.setStart(e.node,e.offset),a.removeAllRanges(),f>d?(a.addRange(b),a.extend(g.node,g.offset)):(b.setEnd(g.node,g.offset),
a.addRange(b)))}b=[];for(a=c;a=a.parentNode;)1===a.nodeType&&b.push({element:a,left:a.scrollLeft,top:a.scrollTop});"function"===typeof c.focus&&c.focus();for(c=0;c<b.length;c++)a=b[c],a.element.scrollLeft=a.left,a.element.scrollTop=a.top}}function dh(a,b,c){var d=c.window===c?c.document:9===c.nodeType?c:c.ownerDocument;Je||null==Jb||Jb!==Qc(d)||(d=Jb,"selectionStart"in d&&Ie(d)?d={start:d.selectionStart,end:d.selectionEnd}:(d=(d.ownerDocument&&d.ownerDocument.defaultView||window).getSelection(),d=
{anchorNode:d.anchorNode,anchorOffset:d.anchorOffset,focusNode:d.focusNode,focusOffset:d.focusOffset}),rc&&qc(rc,d)||(rc=d,d=ed(Ke,"onSelect"),0<d.length&&(b=new He("onSelect","select",null,b,c),a.push({event:b,listeners:d}),b.target=Jb)))}function gd(a,b){var c={};c[a.toLowerCase()]=b.toLowerCase();c["Webkit"+a]="webkit"+b;c["Moz"+a]="moz"+b;return c}function hd(a){if(Le[a])return Le[a];if(!Kb[a])return a;var b=Kb[a],c;for(c in b)if(b.hasOwnProperty(c)&&c in eh)return Le[a]=b[c];return a}function $a(a,
b){fh.set(a,b);mb(b,[a])}function gh(a,b,c){var d=a.type||"unknown-event";a.currentTarget=c;mj(d,b,void 0,a);a.currentTarget=null}function Xg(a,b){b=0!==(b&4);for(var c=0;c<a.length;c++){var d=a[c],e=d.event;d=d.listeners;a:{var f=void 0;if(b)for(var g=d.length-1;0<=g;g--){var h=d[g],k=h.instance,n=h.currentTarget;h=h.listener;if(k!==f&&e.isPropagationStopped())break a;gh(e,h,n);f=k}else for(g=0;g<d.length;g++){h=d[g];k=h.instance;n=h.currentTarget;h=h.listener;if(k!==f&&e.isPropagationStopped())break a;
gh(e,h,n);f=k}}}if(Tc)throw a=ue,Tc=!1,ue=null,a;}function B(a,b){var c=b[Me];void 0===c&&(c=b[Me]=new Set);var d=a+"__bubble";c.has(d)||(hh(b,a,2,!1),c.add(d))}function Ne(a,b,c){var d=0;b&&(d|=4);hh(c,a,d,b)}function sc(a){if(!a[id]){a[id]=!0;cg.forEach(function(b){"selectionchange"!==b&&(Uj.has(b)||Ne(b,!1,a),Ne(b,!0,a))});var b=9===a.nodeType?a:a.ownerDocument;null===b||b[id]||(b[id]=!0,Ne("selectionchange",!1,b))}}function hh(a,b,c,d,e){switch(Lg(b)){case 1:e=zj;break;case 4:e=Aj;break;default:e=
Be}c=e.bind(null,b,c,a);e=void 0;!Oe||"touchstart"!==b&&"touchmove"!==b&&"wheel"!==b||(e=!0);d?void 0!==e?a.addEventListener(b,c,{capture:!0,passive:e}):a.addEventListener(b,c,!0):void 0!==e?a.addEventListener(b,c,{passive:e}):a.addEventListener(b,c,!1)}function Ce(a,b,c,d,e){var f=d;if(0===(b&1)&&0===(b&2)&&null!==d)a:for(;;){if(null===d)return;var g=d.tag;if(3===g||4===g){var h=d.stateNode.containerInfo;if(h===e||8===h.nodeType&&h.parentNode===e)break;if(4===g)for(g=d.return;null!==g;){var k=g.tag;
if(3===k||4===k)if(k=g.stateNode.containerInfo,k===e||8===k.nodeType&&k.parentNode===e)return;g=g.return}for(;null!==h;){g=ob(h);if(null===g)return;k=g.tag;if(5===k||6===k){d=f=g;continue a}h=h.parentNode}}d=d.return}wg(function(){var d=f,e=re(c),g=[];a:{var h=fh.get(a);if(void 0!==h){var k=He,m=a;switch(a){case "keypress":if(0===cd(c))break a;case "keydown":case "keyup":k=Vj;break;case "focusin":m="focus";k=Pe;break;case "focusout":m="blur";k=Pe;break;case "beforeblur":case "afterblur":k=Pe;break;
case "click":if(2===c.button)break a;case "auxclick":case "dblclick":case "mousedown":case "mousemove":case "mouseup":case "mouseout":case "mouseover":case "contextmenu":k=ih;break;case "drag":case "dragend":case "dragenter":case "dragexit":case "dragleave":case "dragover":case "dragstart":case "drop":k=Wj;break;case "touchcancel":case "touchend":case "touchmove":case "touchstart":k=Xj;break;case jh:case kh:case lh:k=Yj;break;case mh:k=Zj;break;case "scroll":k=ak;break;case "wheel":k=bk;break;case "copy":case "cut":case "paste":k=
ck;break;case "gotpointercapture":case "lostpointercapture":case "pointercancel":case "pointerdown":case "pointermove":case "pointerout":case "pointerover":case "pointerup":k=nh}var l=0!==(b&4),p=!l&&"scroll"===a,w=l?null!==h?h+"Capture":null:h;l=[];for(var A=d,t;null!==A;){t=A;var M=t.stateNode;5===t.tag&&null!==M&&(t=M,null!==w&&(M=fc(A,w),null!=M&&l.push(tc(A,M,t))));if(p)break;A=A.return}0<l.length&&(h=new k(h,m,null,c,e),g.push({event:h,listeners:l}))}}if(0===(b&7)){a:{h="mouseover"===a||"pointerover"===
a;k="mouseout"===a||"pointerout"===a;if(h&&c!==ze&&(m=c.relatedTarget||c.fromElement)&&(ob(m)||m[Ja]))break a;if(k||h){h=e.window===e?e:(h=e.ownerDocument)?h.defaultView||h.parentWindow:window;if(k){if(m=c.relatedTarget||c.toElement,k=d,m=m?ob(m):null,null!==m&&(p=nb(m),m!==p||5!==m.tag&&6!==m.tag))m=null}else k=null,m=d;if(k!==m){l=ih;M="onMouseLeave";w="onMouseEnter";A="mouse";if("pointerout"===a||"pointerover"===a)l=nh,M="onPointerLeave",w="onPointerEnter",A="pointer";p=null==k?h:Ib(k);t=null==
m?h:Ib(m);h=new l(M,A+"leave",k,c,e);h.target=p;h.relatedTarget=t;M=null;ob(e)===d&&(l=new l(w,A+"enter",m,c,e),l.target=t,l.relatedTarget=p,M=l);p=M;if(k&&m)b:{l=k;w=m;A=0;for(t=l;t;t=Lb(t))A++;t=0;for(M=w;M;M=Lb(M))t++;for(;0<A-t;)l=Lb(l),A--;for(;0<t-A;)w=Lb(w),t--;for(;A--;){if(l===w||null!==w&&l===w.alternate)break b;l=Lb(l);w=Lb(w)}l=null}else l=null;null!==k&&oh(g,h,k,l,!1);null!==m&&null!==p&&oh(g,p,m,l,!0)}}}a:{h=d?Ib(d):window;k=h.nodeName&&h.nodeName.toLowerCase();if("select"===k||"input"===
k&&"file"===h.type)var ma=Nj;else if(Vg(h))if(ph)ma=Rj;else{ma=Pj;var va=Oj}else(k=h.nodeName)&&"input"===k.toLowerCase()&&("checkbox"===h.type||"radio"===h.type)&&(ma=Qj);if(ma&&(ma=ma(a,d))){Wg(g,ma,c,e);break a}va&&va(a,h,d);"focusout"===a&&(va=h._wrapperState)&&va.controlled&&"number"===h.type&&me(h,"number",h.value)}va=d?Ib(d):window;switch(a){case "focusin":if(Vg(va)||"true"===va.contentEditable)Jb=va,Ke=d,rc=null;break;case "focusout":rc=Ke=Jb=null;break;case "mousedown":Je=!0;break;case "contextmenu":case "mouseup":case "dragend":Je=
!1;dh(g,c,e);break;case "selectionchange":if(dk)break;case "keydown":case "keyup":dh(g,c,e)}var ab;if(Ge)b:{switch(a){case "compositionstart":var da="onCompositionStart";break b;case "compositionend":da="onCompositionEnd";break b;case "compositionupdate":da="onCompositionUpdate";break b}da=void 0}else Hb?Qg(a,c)&&(da="onCompositionEnd"):"keydown"===a&&229===c.keyCode&&(da="onCompositionStart");da&&(Ug&&"ko"!==c.locale&&(Hb||"onCompositionStart"!==da?"onCompositionEnd"===da&&Hb&&(ab=Og()):(Za=e,Ee=
"value"in Za?Za.value:Za.textContent,Hb=!0)),va=ed(d,da),0<va.length&&(da=new qh(da,a,null,c,e),g.push({event:da,listeners:va}),ab?da.data=ab:(ab=Rg(c),null!==ab&&(da.data=ab))));if(ab=ek?Ij(a,c):Jj(a,c))d=ed(d,"onBeforeInput"),0<d.length&&(e=new fk("onBeforeInput","beforeinput",null,c,e),g.push({event:e,listeners:d}),e.data=ab)}Xg(g,b)})}function tc(a,b,c){return{instance:a,listener:b,currentTarget:c}}function ed(a,b){for(var c=b+"Capture",d=[];null!==a;){var e=a,f=e.stateNode;5===e.tag&&null!==
f&&(e=f,f=fc(a,c),null!=f&&d.unshift(tc(a,f,e)),f=fc(a,b),null!=f&&d.push(tc(a,f,e)));a=a.return}return d}function Lb(a){if(null===a)return null;do a=a.return;while(a&&5!==a.tag);return a?a:null}function oh(a,b,c,d,e){for(var f=b._reactName,g=[];null!==c&&c!==d;){var h=c,k=h.alternate,n=h.stateNode;if(null!==k&&k===d)break;5===h.tag&&null!==n&&(h=n,e?(k=fc(c,f),null!=k&&g.unshift(tc(c,k,h))):e||(k=fc(c,f),null!=k&&g.push(tc(c,k,h))));c=c.return}0!==g.length&&a.push({event:b,listeners:g})}function rh(a){return("string"===
typeof a?a:""+a).replace(gk,"\n").replace(hk,"")}function jd(a,b,c,d){b=rh(b);if(rh(a)!==b&&c)throw Error(m(425));}function kd(){}function Qe(a,b){return"textarea"===a||"noscript"===a||"string"===typeof b.children||"number"===typeof b.children||"object"===typeof b.dangerouslySetInnerHTML&&null!==b.dangerouslySetInnerHTML&&null!=b.dangerouslySetInnerHTML.__html}function ik(a){setTimeout(function(){throw a;})}function Re(a,b){var c=b,d=0;do{var e=c.nextSibling;a.removeChild(c);if(e&&8===e.nodeType)if(c=
e.data,"/$"===c){if(0===d){a.removeChild(e);nc(b);return}d--}else"$"!==c&&"$?"!==c&&"$!"!==c||d++;c=e}while(c);nc(b)}function Ka(a){for(;null!=a;a=a.nextSibling){var b=a.nodeType;if(1===b||3===b)break;if(8===b){b=a.data;if("$"===b||"$!"===b||"$?"===b)break;if("/$"===b)return null}}return a}function sh(a){a=a.previousSibling;for(var b=0;a;){if(8===a.nodeType){var c=a.data;if("$"===c||"$!"===c||"$?"===c){if(0===b)return a;b--}else"/$"===c&&b++}a=a.previousSibling}return null}function ob(a){var b=a[Da];
if(b)return b;for(var c=a.parentNode;c;){if(b=c[Ja]||c[Da]){c=b.alternate;if(null!==b.child||null!==c&&null!==c.child)for(a=sh(a);null!==a;){if(c=a[Da])return c;a=sh(a)}return b}a=c;c=a.parentNode}return null}function ec(a){a=a[Da]||a[Ja];return!a||5!==a.tag&&6!==a.tag&&13!==a.tag&&3!==a.tag?null:a}function Ib(a){if(5===a.tag||6===a.tag)return a.stateNode;throw Error(m(33));}function Rc(a){return a[uc]||null}function bb(a){return{current:a}}function v(a,b){0>Mb||(a.current=Se[Mb],Se[Mb]=null,Mb--)}
function y(a,b,c){Mb++;Se[Mb]=a.current;a.current=b}function Nb(a,b){var c=a.type.contextTypes;if(!c)return cb;var d=a.stateNode;if(d&&d.__reactInternalMemoizedUnmaskedChildContext===b)return d.__reactInternalMemoizedMaskedChildContext;var e={},f;for(f in c)e[f]=b[f];d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=b,a.__reactInternalMemoizedMaskedChildContext=e);return e}function ea(a){a=a.childContextTypes;return null!==a&&void 0!==a}function th(a,b,c){if(J.current!==cb)throw Error(m(168));
y(J,b);y(S,c)}function uh(a,b,c){var d=a.stateNode;b=b.childContextTypes;if("function"!==typeof d.getChildContext)return c;d=d.getChildContext();for(var e in d)if(!(e in b))throw Error(m(108,gj(a)||"Unknown",e));return E({},c,d)}function ld(a){a=(a=a.stateNode)&&a.__reactInternalMemoizedMergedChildContext||cb;pb=J.current;y(J,a);y(S,S.current);return!0}function vh(a,b,c){var d=a.stateNode;if(!d)throw Error(m(169));c?(a=uh(a,b,pb),d.__reactInternalMemoizedMergedChildContext=a,v(S),v(J),y(J,a)):v(S);
y(S,c)}function wh(a){null===La?La=[a]:La.push(a)}function jk(a){md=!0;wh(a)}function db(){if(!Te&&null!==La){Te=!0;var a=0,b=z;try{var c=La;for(z=1;a<c.length;a++){var d=c[a];do d=d(!0);while(null!==d)}La=null;md=!1}catch(e){throw null!==La&&(La=La.slice(a+1)),xh(De,db),e;}finally{z=b,Te=!1}}return null}function qb(a,b){Ob[Pb++]=nd;Ob[Pb++]=od;od=a;nd=b}function yh(a,b,c){na[oa++]=Ma;na[oa++]=Na;na[oa++]=rb;rb=a;var d=Ma;a=Na;var e=32-ta(d)-1;d&=~(1<<e);c+=1;var f=32-ta(b)+e;if(30<f){var g=e-e%5;
f=(d&(1<<g)-1).toString(32);d>>=g;e-=g;Ma=1<<32-ta(b)+e|c<<e|d;Na=f+a}else Ma=1<<f|c<<e|d,Na=a}function Ue(a){null!==a.return&&(qb(a,1),yh(a,1,0))}function Ve(a){for(;a===od;)od=Ob[--Pb],Ob[Pb]=null,nd=Ob[--Pb],Ob[Pb]=null;for(;a===rb;)rb=na[--oa],na[oa]=null,Na=na[--oa],na[oa]=null,Ma=na[--oa],na[oa]=null}function zh(a,b){var c=pa(5,null,null,0);c.elementType="DELETED";c.stateNode=b;c.return=a;b=a.deletions;null===b?(a.deletions=[c],a.flags|=16):b.push(c)}function Ah(a,b){switch(a.tag){case 5:var c=
a.type;b=1!==b.nodeType||c.toLowerCase()!==b.nodeName.toLowerCase()?null:b;return null!==b?(a.stateNode=b,la=a,fa=Ka(b.firstChild),!0):!1;case 6:return b=""===a.pendingProps||3!==b.nodeType?null:b,null!==b?(a.stateNode=b,la=a,fa=null,!0):!1;case 13:return b=8!==b.nodeType?null:b,null!==b?(c=null!==rb?{id:Ma,overflow:Na}:null,a.memoizedState={dehydrated:b,treeContext:c,retryLane:1073741824},c=pa(18,null,null,0),c.stateNode=b,c.return=a,a.child=c,la=a,fa=null,!0):!1;default:return!1}}function We(a){return 0!==
(a.mode&1)&&0===(a.flags&128)}function Xe(a){if(D){var b=fa;if(b){var c=b;if(!Ah(a,b)){if(We(a))throw Error(m(418));b=Ka(c.nextSibling);var d=la;b&&Ah(a,b)?zh(d,c):(a.flags=a.flags&-4097|2,D=!1,la=a)}}else{if(We(a))throw Error(m(418));a.flags=a.flags&-4097|2;D=!1;la=a}}}function Bh(a){for(a=a.return;null!==a&&5!==a.tag&&3!==a.tag&&13!==a.tag;)a=a.return;la=a}function pd(a){if(a!==la)return!1;if(!D)return Bh(a),D=!0,!1;var b;(b=3!==a.tag)&&!(b=5!==a.tag)&&(b=a.type,b="head"!==b&&"body"!==b&&!Qe(a.type,
a.memoizedProps));if(b&&(b=fa)){if(We(a)){for(a=fa;a;)a=Ka(a.nextSibling);throw Error(m(418));}for(;b;)zh(a,b),b=Ka(b.nextSibling)}Bh(a);if(13===a.tag){a=a.memoizedState;a=null!==a?a.dehydrated:null;if(!a)throw Error(m(317));a:{a=a.nextSibling;for(b=0;a;){if(8===a.nodeType){var c=a.data;if("/$"===c){if(0===b){fa=Ka(a.nextSibling);break a}b--}else"$"!==c&&"$!"!==c&&"$?"!==c||b++}a=a.nextSibling}fa=null}}else fa=la?Ka(a.stateNode.nextSibling):null;return!0}function Qb(){fa=la=null;D=!1}function Ye(a){null===
wa?wa=[a]:wa.push(a)}function vc(a,b,c){a=c.ref;if(null!==a&&"function"!==typeof a&&"object"!==typeof a){if(c._owner){c=c._owner;if(c){if(1!==c.tag)throw Error(m(309));var d=c.stateNode}if(!d)throw Error(m(147,a));var e=d,f=""+a;if(null!==b&&null!==b.ref&&"function"===typeof b.ref&&b.ref._stringRef===f)return b.ref;b=function(a){var b=e.refs;null===a?delete b[f]:b[f]=a};b._stringRef=f;return b}if("string"!==typeof a)throw Error(m(284));if(!c._owner)throw Error(m(290,a));}return a}function qd(a,b){a=
Object.prototype.toString.call(b);throw Error(m(31,"[object Object]"===a?"object with keys {"+Object.keys(b).join(", ")+"}":a));}function Ch(a){var b=a._init;return b(a._payload)}function Dh(a){function b(b,c){if(a){var d=b.deletions;null===d?(b.deletions=[c],b.flags|=16):d.push(c)}}function c(c,d){if(!a)return null;for(;null!==d;)b(c,d),d=d.sibling;return null}function d(a,b){for(a=new Map;null!==b;)null!==b.key?a.set(b.key,b):a.set(b.index,b),b=b.sibling;return a}function e(a,b){a=eb(a,b);a.index=
0;a.sibling=null;return a}function f(b,c,d){b.index=d;if(!a)return b.flags|=1048576,c;d=b.alternate;if(null!==d)return d=d.index,d<c?(b.flags|=2,c):d;b.flags|=2;return c}function g(b){a&&null===b.alternate&&(b.flags|=2);return b}function h(a,b,c,d){if(null===b||6!==b.tag)return b=Ze(c,a.mode,d),b.return=a,b;b=e(b,c);b.return=a;return b}function k(a,b,c,d){var f=c.type;if(f===Bb)return l(a,b,c.props.children,d,c.key);if(null!==b&&(b.elementType===f||"object"===typeof f&&null!==f&&f.$$typeof===Ta&&
Ch(f)===b.type))return d=e(b,c.props),d.ref=vc(a,b,c),d.return=a,d;d=rd(c.type,c.key,c.props,null,a.mode,d);d.ref=vc(a,b,c);d.return=a;return d}function n(a,b,c,d){if(null===b||4!==b.tag||b.stateNode.containerInfo!==c.containerInfo||b.stateNode.implementation!==c.implementation)return b=$e(c,a.mode,d),b.return=a,b;b=e(b,c.children||[]);b.return=a;return b}function l(a,b,c,d,f){if(null===b||7!==b.tag)return b=sb(c,a.mode,d,f),b.return=a,b;b=e(b,c);b.return=a;return b}function u(a,b,c){if("string"===
typeof b&&""!==b||"number"===typeof b)return b=Ze(""+b,a.mode,c),b.return=a,b;if("object"===typeof b&&null!==b){switch(b.$$typeof){case sd:return c=rd(b.type,b.key,b.props,null,a.mode,c),c.ref=vc(a,null,b),c.return=a,c;case Cb:return b=$e(b,a.mode,c),b.return=a,b;case Ta:var d=b._init;return u(a,d(b._payload),c)}if(cc(b)||ac(b))return b=sb(b,a.mode,c,null),b.return=a,b;qd(a,b)}return null}function r(a,b,c,d){var e=null!==b?b.key:null;if("string"===typeof c&&""!==c||"number"===typeof c)return null!==
e?null:h(a,b,""+c,d);if("object"===typeof c&&null!==c){switch(c.$$typeof){case sd:return c.key===e?k(a,b,c,d):null;case Cb:return c.key===e?n(a,b,c,d):null;case Ta:return e=c._init,r(a,b,e(c._payload),d)}if(cc(c)||ac(c))return null!==e?null:l(a,b,c,d,null);qd(a,c)}return null}function p(a,b,c,d,e){if("string"===typeof d&&""!==d||"number"===typeof d)return a=a.get(c)||null,h(b,a,""+d,e);if("object"===typeof d&&null!==d){switch(d.$$typeof){case sd:return a=a.get(null===d.key?c:d.key)||null,k(b,a,d,
e);case Cb:return a=a.get(null===d.key?c:d.key)||null,n(b,a,d,e);case Ta:var f=d._init;return p(a,b,c,f(d._payload),e)}if(cc(d)||ac(d))return a=a.get(c)||null,l(b,a,d,e,null);qd(b,d)}return null}function x(e,g,h,k){for(var n=null,m=null,l=g,t=g=0,q=null;null!==l&&t<h.length;t++){l.index>t?(q=l,l=null):q=l.sibling;var A=r(e,l,h[t],k);if(null===A){null===l&&(l=q);break}a&&l&&null===A.alternate&&b(e,l);g=f(A,g,t);null===m?n=A:m.sibling=A;m=A;l=q}if(t===h.length)return c(e,l),D&&qb(e,t),n;if(null===l){for(;t<
h.length;t++)l=u(e,h[t],k),null!==l&&(g=f(l,g,t),null===m?n=l:m.sibling=l,m=l);D&&qb(e,t);return n}for(l=d(e,l);t<h.length;t++)q=p(l,e,t,h[t],k),null!==q&&(a&&null!==q.alternate&&l.delete(null===q.key?t:q.key),g=f(q,g,t),null===m?n=q:m.sibling=q,m=q);a&&l.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function I(e,g,h,k){var n=ac(h);if("function"!==typeof n)throw Error(m(150));h=n.call(h);if(null==h)throw Error(m(151));for(var l=n=null,q=g,t=g=0,A=null,w=h.next();null!==q&&!w.done;t++,w=
h.next()){q.index>t?(A=q,q=null):A=q.sibling;var x=r(e,q,w.value,k);if(null===x){null===q&&(q=A);break}a&&q&&null===x.alternate&&b(e,q);g=f(x,g,t);null===l?n=x:l.sibling=x;l=x;q=A}if(w.done)return c(e,q),D&&qb(e,t),n;if(null===q){for(;!w.done;t++,w=h.next())w=u(e,w.value,k),null!==w&&(g=f(w,g,t),null===l?n=w:l.sibling=w,l=w);D&&qb(e,t);return n}for(q=d(e,q);!w.done;t++,w=h.next())w=p(q,e,t,w.value,k),null!==w&&(a&&null!==w.alternate&&q.delete(null===w.key?t:w.key),g=f(w,g,t),null===l?n=w:l.sibling=
w,l=w);a&&q.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function v(a,d,f,h){"object"===typeof f&&null!==f&&f.type===Bb&&null===f.key&&(f=f.props.children);if("object"===typeof f&&null!==f){switch(f.$$typeof){case sd:a:{for(var k=f.key,n=d;null!==n;){if(n.key===k){k=f.type;if(k===Bb){if(7===n.tag){c(a,n.sibling);d=e(n,f.props.children);d.return=a;a=d;break a}}else if(n.elementType===k||"object"===typeof k&&null!==k&&k.$$typeof===Ta&&Ch(k)===n.type){c(a,n.sibling);d=e(n,f.props);d.ref=vc(a,
n,f);d.return=a;a=d;break a}c(a,n);break}else b(a,n);n=n.sibling}f.type===Bb?(d=sb(f.props.children,a.mode,h,f.key),d.return=a,a=d):(h=rd(f.type,f.key,f.props,null,a.mode,h),h.ref=vc(a,d,f),h.return=a,a=h)}return g(a);case Cb:a:{for(n=f.key;null!==d;){if(d.key===n)if(4===d.tag&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){c(a,d.sibling);d=e(d,f.children||[]);d.return=a;a=d;break a}else{c(a,d);break}else b(a,d);d=d.sibling}d=$e(f,a.mode,h);d.return=a;
a=d}return g(a);case Ta:return n=f._init,v(a,d,n(f._payload),h)}if(cc(f))return x(a,d,f,h);if(ac(f))return I(a,d,f,h);qd(a,f)}return"string"===typeof f&&""!==f||"number"===typeof f?(f=""+f,null!==d&&6===d.tag?(c(a,d.sibling),d=e(d,f),d.return=a,a=d):(c(a,d),d=Ze(f,a.mode,h),d.return=a,a=d),g(a)):c(a,d)}return v}function af(){bf=Rb=td=null}function cf(a,b){b=ud.current;v(ud);a._currentValue=b}function df(a,b,c){for(;null!==a;){var d=a.alternate;(a.childLanes&b)!==b?(a.childLanes|=b,null!==d&&(d.childLanes|=
b)):null!==d&&(d.childLanes&b)!==b&&(d.childLanes|=b);if(a===c)break;a=a.return}}function Sb(a,b){td=a;bf=Rb=null;a=a.dependencies;null!==a&&null!==a.firstContext&&(0!==(a.lanes&b)&&(ha=!0),a.firstContext=null)}function qa(a){var b=a._currentValue;if(bf!==a)if(a={context:a,memoizedValue:b,next:null},null===Rb){if(null===td)throw Error(m(308));Rb=a;td.dependencies={lanes:0,firstContext:a}}else Rb=Rb.next=a;return b}function ef(a){null===tb?tb=[a]:tb.push(a)}function Eh(a,b,c,d){var e=b.interleaved;
null===e?(c.next=c,ef(b)):(c.next=e.next,e.next=c);b.interleaved=c;return Oa(a,d)}function Oa(a,b){a.lanes|=b;var c=a.alternate;null!==c&&(c.lanes|=b);c=a;for(a=a.return;null!==a;)a.childLanes|=b,c=a.alternate,null!==c&&(c.childLanes|=b),c=a,a=a.return;return 3===c.tag?c.stateNode:null}function ff(a){a.updateQueue={baseState:a.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Fh(a,b){a=a.updateQueue;b.updateQueue===a&&(b.updateQueue=
{baseState:a.baseState,firstBaseUpdate:a.firstBaseUpdate,lastBaseUpdate:a.lastBaseUpdate,shared:a.shared,effects:a.effects})}function Pa(a,b){return{eventTime:a,lane:b,tag:0,payload:null,callback:null,next:null}}function fb(a,b,c){var d=a.updateQueue;if(null===d)return null;d=d.shared;if(0!==(p&2)){var e=d.pending;null===e?b.next=b:(b.next=e.next,e.next=b);d.pending=b;return kk(a,c)}e=d.interleaved;null===e?(b.next=b,ef(d)):(b.next=e.next,e.next=b);d.interleaved=b;return Oa(a,c)}function vd(a,b,c){b=
b.updateQueue;if(null!==b&&(b=b.shared,0!==(c&4194240))){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function Gh(a,b){var c=a.updateQueue,d=a.alternate;if(null!==d&&(d=d.updateQueue,c===d)){var e=null,f=null;c=c.firstBaseUpdate;if(null!==c){do{var g={eventTime:c.eventTime,lane:c.lane,tag:c.tag,payload:c.payload,callback:c.callback,next:null};null===f?e=f=g:f=f.next=g;c=c.next}while(null!==c);null===f?e=f=b:f=f.next=b}else e=f=b;c={baseState:d.baseState,firstBaseUpdate:e,lastBaseUpdate:f,
shared:d.shared,effects:d.effects};a.updateQueue=c;return}a=c.lastBaseUpdate;null===a?c.firstBaseUpdate=b:a.next=b;c.lastBaseUpdate=b}function wd(a,b,c,d){var e=a.updateQueue;gb=!1;var f=e.firstBaseUpdate,g=e.lastBaseUpdate,h=e.shared.pending;if(null!==h){e.shared.pending=null;var k=h,n=k.next;k.next=null;null===g?f=n:g.next=n;g=k;var l=a.alternate;null!==l&&(l=l.updateQueue,h=l.lastBaseUpdate,h!==g&&(null===h?l.firstBaseUpdate=n:h.next=n,l.lastBaseUpdate=k))}if(null!==f){var m=e.baseState;g=0;l=
n=k=null;h=f;do{var r=h.lane,p=h.eventTime;if((d&r)===r){null!==l&&(l=l.next={eventTime:p,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,next:null});a:{var x=a,v=h;r=b;p=c;switch(v.tag){case 1:x=v.payload;if("function"===typeof x){m=x.call(p,m,r);break a}m=x;break a;case 3:x.flags=x.flags&-65537|128;case 0:x=v.payload;r="function"===typeof x?x.call(p,m,r):x;if(null===r||void 0===r)break a;m=E({},m,r);break a;case 2:gb=!0}}null!==h.callback&&0!==h.lane&&(a.flags|=64,r=e.effects,null===r?e.effects=
[h]:r.push(h))}else p={eventTime:p,lane:r,tag:h.tag,payload:h.payload,callback:h.callback,next:null},null===l?(n=l=p,k=m):l=l.next=p,g|=r;h=h.next;if(null===h)if(h=e.shared.pending,null===h)break;else r=h,h=r.next,r.next=null,e.lastBaseUpdate=r,e.shared.pending=null}while(1);null===l&&(k=m);e.baseState=k;e.firstBaseUpdate=n;e.lastBaseUpdate=l;b=e.shared.interleaved;if(null!==b){e=b;do g|=e.lane,e=e.next;while(e!==b)}else null===f&&(e.shared.lanes=0);ra|=g;a.lanes=g;a.memoizedState=m}}function Hh(a,
b,c){a=b.effects;b.effects=null;if(null!==a)for(b=0;b<a.length;b++){var d=a[b],e=d.callback;if(null!==e){d.callback=null;d=c;if("function"!==typeof e)throw Error(m(191,e));e.call(d)}}}function ub(a){if(a===wc)throw Error(m(174));return a}function gf(a,b){y(xc,b);y(yc,a);y(Ea,wc);a=b.nodeType;switch(a){case 9:case 11:b=(b=b.documentElement)?b.namespaceURI:oe(null,"");break;default:a=8===a?b.parentNode:b,b=a.namespaceURI||null,a=a.tagName,b=oe(b,a)}v(Ea);y(Ea,b)}function Tb(a){v(Ea);v(yc);v(xc)}function Ih(a){ub(xc.current);
var b=ub(Ea.current);var c=oe(b,a.type);b!==c&&(y(yc,a),y(Ea,c))}function hf(a){yc.current===a&&(v(Ea),v(yc))}function xd(a){for(var b=a;null!==b;){if(13===b.tag){var c=b.memoizedState;if(null!==c&&(c=c.dehydrated,null===c||"$?"===c.data||"$!"===c.data))return b}else if(19===b.tag&&void 0!==b.memoizedProps.revealOrder){if(0!==(b.flags&128))return b}else if(null!==b.child){b.child.return=b;b=b.child;continue}if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return null;b=b.return}b.sibling.return=
b.return;b=b.sibling}return null}function jf(){for(var a=0;a<kf.length;a++)kf[a]._workInProgressVersionPrimary=null;kf.length=0}function V(){throw Error(m(321));}function lf(a,b){if(null===b)return!1;for(var c=0;c<b.length&&c<a.length;c++)if(!ua(a[c],b[c]))return!1;return!0}function mf(a,b,c,d,e,f){vb=f;C=b;b.memoizedState=null;b.updateQueue=null;b.lanes=0;yd.current=null===a||null===a.memoizedState?lk:mk;a=c(d,e);if(zc){f=0;do{zc=!1;Ac=0;if(25<=f)throw Error(m(301));f+=1;N=K=null;b.updateQueue=null;
yd.current=nk;a=c(d,e)}while(zc)}yd.current=zd;b=null!==K&&null!==K.next;vb=0;N=K=C=null;Ad=!1;if(b)throw Error(m(300));return a}function nf(){var a=0!==Ac;Ac=0;return a}function Fa(){var a={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};null===N?C.memoizedState=N=a:N=N.next=a;return N}function sa(){if(null===K){var a=C.alternate;a=null!==a?a.memoizedState:null}else a=K.next;var b=null===N?C.memoizedState:N.next;if(null!==b)N=b,K=a;else{if(null===a)throw Error(m(310));K=a;
a={memoizedState:K.memoizedState,baseState:K.baseState,baseQueue:K.baseQueue,queue:K.queue,next:null};null===N?C.memoizedState=N=a:N=N.next=a}return N}function Bc(a,b){return"function"===typeof b?b(a):b}function of(a,b,c){b=sa();c=b.queue;if(null===c)throw Error(m(311));c.lastRenderedReducer=a;var d=K,e=d.baseQueue,f=c.pending;if(null!==f){if(null!==e){var g=e.next;e.next=f.next;f.next=g}d.baseQueue=e=f;c.pending=null}if(null!==e){f=e.next;d=d.baseState;var h=g=null,k=null,n=f;do{var l=n.lane;if((vb&
l)===l)null!==k&&(k=k.next={lane:0,action:n.action,hasEagerState:n.hasEagerState,eagerState:n.eagerState,next:null}),d=n.hasEagerState?n.eagerState:a(d,n.action);else{var u={lane:l,action:n.action,hasEagerState:n.hasEagerState,eagerState:n.eagerState,next:null};null===k?(h=k=u,g=d):k=k.next=u;C.lanes|=l;ra|=l}n=n.next}while(null!==n&&n!==f);null===k?g=d:k.next=h;ua(d,b.memoizedState)||(ha=!0);b.memoizedState=d;b.baseState=g;b.baseQueue=k;c.lastRenderedState=d}a=c.interleaved;if(null!==a){e=a;do f=
e.lane,C.lanes|=f,ra|=f,e=e.next;while(e!==a)}else null===e&&(c.lanes=0);return[b.memoizedState,c.dispatch]}function pf(a,b,c){b=sa();c=b.queue;if(null===c)throw Error(m(311));c.lastRenderedReducer=a;var d=c.dispatch,e=c.pending,f=b.memoizedState;if(null!==e){c.pending=null;var g=e=e.next;do f=a(f,g.action),g=g.next;while(g!==e);ua(f,b.memoizedState)||(ha=!0);b.memoizedState=f;null===b.baseQueue&&(b.baseState=f);c.lastRenderedState=f}return[f,d]}function Jh(a,b,c){}function Kh(a,b,c){c=C;var d=sa(),
e=b(),f=!ua(d.memoizedState,e);f&&(d.memoizedState=e,ha=!0);d=d.queue;qf(Lh.bind(null,c,d,a),[a]);if(d.getSnapshot!==b||f||null!==N&&N.memoizedState.tag&1){c.flags|=2048;Cc(9,Mh.bind(null,c,d,e,b),void 0,null);if(null===O)throw Error(m(349));0!==(vb&30)||Nh(c,b,e)}return e}function Nh(a,b,c){a.flags|=16384;a={getSnapshot:b,value:c};b=C.updateQueue;null===b?(b={lastEffect:null,stores:null},C.updateQueue=b,b.stores=[a]):(c=b.stores,null===c?b.stores=[a]:c.push(a))}function Mh(a,b,c,d){b.value=c;b.getSnapshot=
d;Oh(b)&&Ph(a)}function Lh(a,b,c){return c(function(){Oh(b)&&Ph(a)})}function Oh(a){var b=a.getSnapshot;a=a.value;try{var c=b();return!ua(a,c)}catch(d){return!0}}function Ph(a){var b=Oa(a,1);null!==b&&xa(b,a,1,-1)}function Qh(a){var b=Fa();"function"===typeof a&&(a=a());b.memoizedState=b.baseState=a;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:Bc,lastRenderedState:a};b.queue=a;a=a.dispatch=ok.bind(null,C,a);return[b.memoizedState,a]}function Cc(a,b,c,d){a={tag:a,create:b,
destroy:c,deps:d,next:null};b=C.updateQueue;null===b?(b={lastEffect:null,stores:null},C.updateQueue=b,b.lastEffect=a.next=a):(c=b.lastEffect,null===c?b.lastEffect=a.next=a:(d=c.next,c.next=a,a.next=d,b.lastEffect=a));return a}function Rh(a){return sa().memoizedState}function Bd(a,b,c,d){var e=Fa();C.flags|=a;e.memoizedState=Cc(1|b,c,void 0,void 0===d?null:d)}function Cd(a,b,c,d){var e=sa();d=void 0===d?null:d;var f=void 0;if(null!==K){var g=K.memoizedState;f=g.destroy;if(null!==d&&lf(d,g.deps)){e.memoizedState=
Cc(b,c,f,d);return}}C.flags|=a;e.memoizedState=Cc(1|b,c,f,d)}function Sh(a,b){return Bd(8390656,8,a,b)}function qf(a,b){return Cd(2048,8,a,b)}function Th(a,b){return Cd(4,2,a,b)}function Uh(a,b){return Cd(4,4,a,b)}function Vh(a,b){if("function"===typeof b)return a=a(),b(a),function(){b(null)};if(null!==b&&void 0!==b)return a=a(),b.current=a,function(){b.current=null}}function Wh(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Cd(4,4,Vh.bind(null,b,a),c)}function rf(a,b){}function Xh(a,b){var c=
sa();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&lf(b,d[1]))return d[0];c.memoizedState=[a,b];return a}function Yh(a,b){var c=sa();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&lf(b,d[1]))return d[0];a=a();c.memoizedState=[a,b];return a}function Zh(a,b,c){if(0===(vb&21))return a.baseState&&(a.baseState=!1,ha=!0),a.memoizedState=c;ua(c,b)||(c=Dg(),C.lanes|=c,ra|=c,a.baseState=!0);return b}function pk(a,b,c){c=z;z=0!==c&&4>c?c:4;a(!0);var d=sf.transition;sf.transition=
{};try{a(!1),b()}finally{z=c,sf.transition=d}}function $h(){return sa().memoizedState}function qk(a,b,c){var d=hb(a);c={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,c);else if(c=Eh(a,b,c,d),null!==c){var e=Z();xa(c,a,d,e);ci(c,b,d)}}function ok(a,b,c){var d=hb(a),e={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,e);else{var f=a.alternate;if(0===a.lanes&&(null===f||0===f.lanes)&&(f=b.lastRenderedReducer,null!==f))try{var g=b.lastRenderedState,
h=f(g,c);e.hasEagerState=!0;e.eagerState=h;if(ua(h,g)){var k=b.interleaved;null===k?(e.next=e,ef(b)):(e.next=k.next,k.next=e);b.interleaved=e;return}}catch(n){}finally{}c=Eh(a,b,e,d);null!==c&&(e=Z(),xa(c,a,d,e),ci(c,b,d))}}function ai(a){var b=a.alternate;return a===C||null!==b&&b===C}function bi(a,b){zc=Ad=!0;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b}function ci(a,b,c){if(0!==(c&4194240)){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function ya(a,b){if(a&&
a.defaultProps){b=E({},b);a=a.defaultProps;for(var c in a)void 0===b[c]&&(b[c]=a[c]);return b}return b}function tf(a,b,c,d){b=a.memoizedState;c=c(d,b);c=null===c||void 0===c?b:E({},b,c);a.memoizedState=c;0===a.lanes&&(a.updateQueue.baseState=c)}function di(a,b,c,d,e,f,g){a=a.stateNode;return"function"===typeof a.shouldComponentUpdate?a.shouldComponentUpdate(d,f,g):b.prototype&&b.prototype.isPureReactComponent?!qc(c,d)||!qc(e,f):!0}function ei(a,b,c){var d=!1,e=cb;var f=b.contextType;"object"===typeof f&&
null!==f?f=qa(f):(e=ea(b)?pb:J.current,d=b.contextTypes,f=(d=null!==d&&void 0!==d)?Nb(a,e):cb);b=new b(c,f);a.memoizedState=null!==b.state&&void 0!==b.state?b.state:null;b.updater=Dd;a.stateNode=b;b._reactInternals=a;d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=e,a.__reactInternalMemoizedMaskedChildContext=f);return b}function fi(a,b,c,d){a=b.state;"function"===typeof b.componentWillReceiveProps&&b.componentWillReceiveProps(c,d);"function"===typeof b.UNSAFE_componentWillReceiveProps&&
b.UNSAFE_componentWillReceiveProps(c,d);b.state!==a&&Dd.enqueueReplaceState(b,b.state,null)}function uf(a,b,c,d){var e=a.stateNode;e.props=c;e.state=a.memoizedState;e.refs={};ff(a);var f=b.contextType;"object"===typeof f&&null!==f?e.context=qa(f):(f=ea(b)?pb:J.current,e.context=Nb(a,f));e.state=a.memoizedState;f=b.getDerivedStateFromProps;"function"===typeof f&&(tf(a,b,f,c),e.state=a.memoizedState);"function"===typeof b.getDerivedStateFromProps||"function"===typeof e.getSnapshotBeforeUpdate||"function"!==
typeof e.UNSAFE_componentWillMount&&"function"!==typeof e.componentWillMount||(b=e.state,"function"===typeof e.componentWillMount&&e.componentWillMount(),"function"===typeof e.UNSAFE_componentWillMount&&e.UNSAFE_componentWillMount(),b!==e.state&&Dd.enqueueReplaceState(e,e.state,null),wd(a,c,e,d),e.state=a.memoizedState);"function"===typeof e.componentDidMount&&(a.flags|=4194308)}function Ub(a,b){try{var c="",d=b;do c+=fj(d),d=d.return;while(d);var e=c}catch(f){e="\nError generating stack: "+f.message+
"\n"+f.stack}return{value:a,source:b,stack:e,digest:null}}function vf(a,b,c){return{value:a,source:null,stack:null!=c?c:null,digest:null!=b?b:null}}function wf(a,b){try{console.error(b.value)}catch(c){setTimeout(function(){throw c;})}}function gi(a,b,c){c=Pa(-1,c);c.tag=3;c.payload={element:null};var d=b.value;c.callback=function(){Ed||(Ed=!0,xf=d);wf(a,b)};return c}function hi(a,b,c){c=Pa(-1,c);c.tag=3;var d=a.type.getDerivedStateFromError;if("function"===typeof d){var e=b.value;c.payload=function(){return d(e)};
c.callback=function(){wf(a,b)}}var f=a.stateNode;null!==f&&"function"===typeof f.componentDidCatch&&(c.callback=function(){wf(a,b);"function"!==typeof d&&(null===ib?ib=new Set([this]):ib.add(this));var c=b.stack;this.componentDidCatch(b.value,{componentStack:null!==c?c:""})});return c}function ii(a,b,c){var d=a.pingCache;if(null===d){d=a.pingCache=new rk;var e=new Set;d.set(b,e)}else e=d.get(b),void 0===e&&(e=new Set,d.set(b,e));e.has(c)||(e.add(c),a=sk.bind(null,a,b,c),b.then(a,a))}function ji(a){do{var b;
if(b=13===a.tag)b=a.memoizedState,b=null!==b?null!==b.dehydrated?!0:!1:!0;if(b)return a;a=a.return}while(null!==a);return null}function ki(a,b,c,d,e){if(0===(a.mode&1))return a===b?a.flags|=65536:(a.flags|=128,c.flags|=131072,c.flags&=-52805,1===c.tag&&(null===c.alternate?c.tag=17:(b=Pa(-1,1),b.tag=2,fb(c,b,1))),c.lanes|=1),a;a.flags|=65536;a.lanes=e;return a}function aa(a,b,c,d){b.child=null===a?li(b,null,c,d):Vb(b,a.child,c,d)}function mi(a,b,c,d,e){c=c.render;var f=b.ref;Sb(b,e);d=mf(a,b,c,d,f,
e);c=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&c&&Ue(b);b.flags|=1;aa(a,b,d,e);return b.child}function ni(a,b,c,d,e){if(null===a){var f=c.type;if("function"===typeof f&&!yf(f)&&void 0===f.defaultProps&&null===c.compare&&void 0===c.defaultProps)return b.tag=15,b.type=f,oi(a,b,f,d,e);a=rd(c.type,null,d,b,b.mode,e);a.ref=b.ref;a.return=b;return b.child=a}f=a.child;if(0===(a.lanes&e)){var g=f.memoizedProps;c=c.compare;c=null!==c?c:qc;if(c(g,d)&&a.ref===
b.ref)return Qa(a,b,e)}b.flags|=1;a=eb(f,d);a.ref=b.ref;a.return=b;return b.child=a}function oi(a,b,c,d,e){if(null!==a){var f=a.memoizedProps;if(qc(f,d)&&a.ref===b.ref)if(ha=!1,b.pendingProps=d=f,0!==(a.lanes&e))0!==(a.flags&131072)&&(ha=!0);else return b.lanes=a.lanes,Qa(a,b,e)}return zf(a,b,c,d,e)}function pi(a,b,c){var d=b.pendingProps,e=d.children,f=null!==a?a.memoizedState:null;if("hidden"===d.mode)if(0===(b.mode&1))b.memoizedState={baseLanes:0,cachePool:null,transitions:null},y(Ga,ba),ba|=c;
else{if(0===(c&1073741824))return a=null!==f?f.baseLanes|c:c,b.lanes=b.childLanes=1073741824,b.memoizedState={baseLanes:a,cachePool:null,transitions:null},b.updateQueue=null,y(Ga,ba),ba|=a,null;b.memoizedState={baseLanes:0,cachePool:null,transitions:null};d=null!==f?f.baseLanes:c;y(Ga,ba);ba|=d}else null!==f?(d=f.baseLanes|c,b.memoizedState=null):d=c,y(Ga,ba),ba|=d;aa(a,b,e,c);return b.child}function qi(a,b){var c=b.ref;if(null===a&&null!==c||null!==a&&a.ref!==c)b.flags|=512,b.flags|=2097152}function zf(a,
b,c,d,e){var f=ea(c)?pb:J.current;f=Nb(b,f);Sb(b,e);c=mf(a,b,c,d,f,e);d=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&d&&Ue(b);b.flags|=1;aa(a,b,c,e);return b.child}function ri(a,b,c,d,e){if(ea(c)){var f=!0;ld(b)}else f=!1;Sb(b,e);if(null===b.stateNode)Fd(a,b),ei(b,c,d),uf(b,c,d,e),d=!0;else if(null===a){var g=b.stateNode,h=b.memoizedProps;g.props=h;var k=g.context,n=c.contextType;"object"===typeof n&&null!==n?n=qa(n):(n=ea(c)?pb:J.current,n=Nb(b,
n));var l=c.getDerivedStateFromProps,m="function"===typeof l||"function"===typeof g.getSnapshotBeforeUpdate;m||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==d||k!==n)&&fi(b,g,d,n);gb=!1;var r=b.memoizedState;g.state=r;wd(b,d,g,e);k=b.memoizedState;h!==d||r!==k||S.current||gb?("function"===typeof l&&(tf(b,c,l,d),k=b.memoizedState),(h=gb||di(b,c,h,d,r,k,n))?(m||"function"!==typeof g.UNSAFE_componentWillMount&&"function"!==typeof g.componentWillMount||
("function"===typeof g.componentWillMount&&g.componentWillMount(),"function"===typeof g.UNSAFE_componentWillMount&&g.UNSAFE_componentWillMount()),"function"===typeof g.componentDidMount&&(b.flags|=4194308)):("function"===typeof g.componentDidMount&&(b.flags|=4194308),b.memoizedProps=d,b.memoizedState=k),g.props=d,g.state=k,g.context=n,d=h):("function"===typeof g.componentDidMount&&(b.flags|=4194308),d=!1)}else{g=b.stateNode;Fh(a,b);h=b.memoizedProps;n=b.type===b.elementType?h:ya(b.type,h);g.props=
n;m=b.pendingProps;r=g.context;k=c.contextType;"object"===typeof k&&null!==k?k=qa(k):(k=ea(c)?pb:J.current,k=Nb(b,k));var p=c.getDerivedStateFromProps;(l="function"===typeof p||"function"===typeof g.getSnapshotBeforeUpdate)||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==m||r!==k)&&fi(b,g,d,k);gb=!1;r=b.memoizedState;g.state=r;wd(b,d,g,e);var x=b.memoizedState;h!==m||r!==x||S.current||gb?("function"===typeof p&&(tf(b,c,p,d),x=b.memoizedState),
(n=gb||di(b,c,n,d,r,x,k)||!1)?(l||"function"!==typeof g.UNSAFE_componentWillUpdate&&"function"!==typeof g.componentWillUpdate||("function"===typeof g.componentWillUpdate&&g.componentWillUpdate(d,x,k),"function"===typeof g.UNSAFE_componentWillUpdate&&g.UNSAFE_componentWillUpdate(d,x,k)),"function"===typeof g.componentDidUpdate&&(b.flags|=4),"function"===typeof g.getSnapshotBeforeUpdate&&(b.flags|=1024)):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=
4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),b.memoizedProps=d,b.memoizedState=x),g.props=d,g.state=x,g.context=k,d=n):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),d=!1)}return Af(a,b,c,d,f,e)}function Af(a,b,c,d,e,f){qi(a,b);var g=0!==(b.flags&128);if(!d&&!g)return e&&vh(b,c,!1),
Qa(a,b,f);d=b.stateNode;tk.current=b;var h=g&&"function"!==typeof c.getDerivedStateFromError?null:d.render();b.flags|=1;null!==a&&g?(b.child=Vb(b,a.child,null,f),b.child=Vb(b,null,h,f)):aa(a,b,h,f);b.memoizedState=d.state;e&&vh(b,c,!0);return b.child}function si(a){var b=a.stateNode;b.pendingContext?th(a,b.pendingContext,b.pendingContext!==b.context):b.context&&th(a,b.context,!1);gf(a,b.containerInfo)}function ti(a,b,c,d,e){Qb();Ye(e);b.flags|=256;aa(a,b,c,d);return b.child}function Bf(a){return{baseLanes:a,
cachePool:null,transitions:null}}function ui(a,b,c){var d=b.pendingProps,e=F.current,f=!1,g=0!==(b.flags&128),h;(h=g)||(h=null!==a&&null===a.memoizedState?!1:0!==(e&2));if(h)f=!0,b.flags&=-129;else if(null===a||null!==a.memoizedState)e|=1;y(F,e&1);if(null===a){Xe(b);a=b.memoizedState;if(null!==a&&(a=a.dehydrated,null!==a))return 0===(b.mode&1)?b.lanes=1:"$!"===a.data?b.lanes=8:b.lanes=1073741824,null;g=d.children;a=d.fallback;return f?(d=b.mode,f=b.child,g={mode:"hidden",children:g},0===(d&1)&&null!==
f?(f.childLanes=0,f.pendingProps=g):f=Gd(g,d,0,null),a=sb(a,d,c,null),f.return=b,a.return=b,f.sibling=a,b.child=f,b.child.memoizedState=Bf(c),b.memoizedState=Cf,a):Df(b,g)}e=a.memoizedState;if(null!==e&&(h=e.dehydrated,null!==h))return uk(a,b,g,d,h,e,c);if(f){f=d.fallback;g=b.mode;e=a.child;h=e.sibling;var k={mode:"hidden",children:d.children};0===(g&1)&&b.child!==e?(d=b.child,d.childLanes=0,d.pendingProps=k,b.deletions=null):(d=eb(e,k),d.subtreeFlags=e.subtreeFlags&14680064);null!==h?f=eb(h,f):(f=
sb(f,g,c,null),f.flags|=2);f.return=b;d.return=b;d.sibling=f;b.child=d;d=f;f=b.child;g=a.child.memoizedState;g=null===g?Bf(c):{baseLanes:g.baseLanes|c,cachePool:null,transitions:g.transitions};f.memoizedState=g;f.childLanes=a.childLanes&~c;b.memoizedState=Cf;return d}f=a.child;a=f.sibling;d=eb(f,{mode:"visible",children:d.children});0===(b.mode&1)&&(d.lanes=c);d.return=b;d.sibling=null;null!==a&&(c=b.deletions,null===c?(b.deletions=[a],b.flags|=16):c.push(a));b.child=d;b.memoizedState=null;return d}
function Df(a,b,c){b=Gd({mode:"visible",children:b},a.mode,0,null);b.return=a;return a.child=b}function Hd(a,b,c,d){null!==d&&Ye(d);Vb(b,a.child,null,c);a=Df(b,b.pendingProps.children);a.flags|=2;b.memoizedState=null;return a}function uk(a,b,c,d,e,f,g){if(c){if(b.flags&256)return b.flags&=-257,d=vf(Error(m(422))),Hd(a,b,g,d);if(null!==b.memoizedState)return b.child=a.child,b.flags|=128,null;f=d.fallback;e=b.mode;d=Gd({mode:"visible",children:d.children},e,0,null);f=sb(f,e,g,null);f.flags|=2;d.return=
b;f.return=b;d.sibling=f;b.child=d;0!==(b.mode&1)&&Vb(b,a.child,null,g);b.child.memoizedState=Bf(g);b.memoizedState=Cf;return f}if(0===(b.mode&1))return Hd(a,b,g,null);if("$!"===e.data){d=e.nextSibling&&e.nextSibling.dataset;if(d)var h=d.dgst;d=h;f=Error(m(419));d=vf(f,d,void 0);return Hd(a,b,g,d)}h=0!==(g&a.childLanes);if(ha||h){d=O;if(null!==d){switch(g&-g){case 4:e=2;break;case 16:e=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:e=
32;break;case 536870912:e=268435456;break;default:e=0}e=0!==(e&(d.suspendedLanes|g))?0:e;0!==e&&e!==f.retryLane&&(f.retryLane=e,Oa(a,e),xa(d,a,e,-1))}Ef();d=vf(Error(m(421)));return Hd(a,b,g,d)}if("$?"===e.data)return b.flags|=128,b.child=a.child,b=vk.bind(null,a),e._reactRetry=b,null;a=f.treeContext;fa=Ka(e.nextSibling);la=b;D=!0;wa=null;null!==a&&(na[oa++]=Ma,na[oa++]=Na,na[oa++]=rb,Ma=a.id,Na=a.overflow,rb=b);b=Df(b,d.children);b.flags|=4096;return b}function vi(a,b,c){a.lanes|=b;var d=a.alternate;
null!==d&&(d.lanes|=b);df(a.return,b,c)}function Ff(a,b,c,d,e){var f=a.memoizedState;null===f?a.memoizedState={isBackwards:b,rendering:null,renderingStartTime:0,last:d,tail:c,tailMode:e}:(f.isBackwards=b,f.rendering=null,f.renderingStartTime=0,f.last=d,f.tail=c,f.tailMode=e)}function wi(a,b,c){var d=b.pendingProps,e=d.revealOrder,f=d.tail;aa(a,b,d.children,c);d=F.current;if(0!==(d&2))d=d&1|2,b.flags|=128;else{if(null!==a&&0!==(a.flags&128))a:for(a=b.child;null!==a;){if(13===a.tag)null!==a.memoizedState&&
vi(a,c,b);else if(19===a.tag)vi(a,c,b);else if(null!==a.child){a.child.return=a;a=a.child;continue}if(a===b)break a;for(;null===a.sibling;){if(null===a.return||a.return===b)break a;a=a.return}a.sibling.return=a.return;a=a.sibling}d&=1}y(F,d);if(0===(b.mode&1))b.memoizedState=null;else switch(e){case "forwards":c=b.child;for(e=null;null!==c;)a=c.alternate,null!==a&&null===xd(a)&&(e=c),c=c.sibling;c=e;null===c?(e=b.child,b.child=null):(e=c.sibling,c.sibling=null);Ff(b,!1,e,c,f);break;case "backwards":c=
null;e=b.child;for(b.child=null;null!==e;){a=e.alternate;if(null!==a&&null===xd(a)){b.child=e;break}a=e.sibling;e.sibling=c;c=e;e=a}Ff(b,!0,c,null,f);break;case "together":Ff(b,!1,null,null,void 0);break;default:b.memoizedState=null}return b.child}function Fd(a,b){0===(b.mode&1)&&null!==a&&(a.alternate=null,b.alternate=null,b.flags|=2)}function Qa(a,b,c){null!==a&&(b.dependencies=a.dependencies);ra|=b.lanes;if(0===(c&b.childLanes))return null;if(null!==a&&b.child!==a.child)throw Error(m(153));if(null!==
b.child){a=b.child;c=eb(a,a.pendingProps);b.child=c;for(c.return=b;null!==a.sibling;)a=a.sibling,c=c.sibling=eb(a,a.pendingProps),c.return=b;c.sibling=null}return b.child}function wk(a,b,c){switch(b.tag){case 3:si(b);Qb();break;case 5:Ih(b);break;case 1:ea(b.type)&&ld(b);break;case 4:gf(b,b.stateNode.containerInfo);break;case 10:var d=b.type._context,e=b.memoizedProps.value;y(ud,d._currentValue);d._currentValue=e;break;case 13:d=b.memoizedState;if(null!==d){if(null!==d.dehydrated)return y(F,F.current&
1),b.flags|=128,null;if(0!==(c&b.child.childLanes))return ui(a,b,c);y(F,F.current&1);a=Qa(a,b,c);return null!==a?a.sibling:null}y(F,F.current&1);break;case 19:d=0!==(c&b.childLanes);if(0!==(a.flags&128)){if(d)return wi(a,b,c);b.flags|=128}e=b.memoizedState;null!==e&&(e.rendering=null,e.tail=null,e.lastEffect=null);y(F,F.current);if(d)break;else return null;case 22:case 23:return b.lanes=0,pi(a,b,c)}return Qa(a,b,c)}function Dc(a,b){if(!D)switch(a.tailMode){case "hidden":b=a.tail;for(var c=null;null!==
b;)null!==b.alternate&&(c=b),b=b.sibling;null===c?a.tail=null:c.sibling=null;break;case "collapsed":c=a.tail;for(var d=null;null!==c;)null!==c.alternate&&(d=c),c=c.sibling;null===d?b||null===a.tail?a.tail=null:a.tail.sibling=null:d.sibling=null}}function W(a){var b=null!==a.alternate&&a.alternate.child===a.child,c=0,d=0;if(b)for(var e=a.child;null!==e;)c|=e.lanes|e.childLanes,d|=e.subtreeFlags&14680064,d|=e.flags&14680064,e.return=a,e=e.sibling;else for(e=a.child;null!==e;)c|=e.lanes|e.childLanes,
d|=e.subtreeFlags,d|=e.flags,e.return=a,e=e.sibling;a.subtreeFlags|=d;a.childLanes=c;return b}function xk(a,b,c){var d=b.pendingProps;Ve(b);switch(b.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return W(b),null;case 1:return ea(b.type)&&(v(S),v(J)),W(b),null;case 3:d=b.stateNode;Tb();v(S);v(J);jf();d.pendingContext&&(d.context=d.pendingContext,d.pendingContext=null);if(null===a||null===a.child)pd(b)?b.flags|=4:null===a||a.memoizedState.isDehydrated&&0===(b.flags&
256)||(b.flags|=1024,null!==wa&&(Gf(wa),wa=null));xi(a,b);W(b);return null;case 5:hf(b);var e=ub(xc.current);c=b.type;if(null!==a&&null!=b.stateNode)yk(a,b,c,d,e),a.ref!==b.ref&&(b.flags|=512,b.flags|=2097152);else{if(!d){if(null===b.stateNode)throw Error(m(166));W(b);return null}a=ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.type;var f=b.memoizedProps;d[Da]=b;d[uc]=f;a=0!==(b.mode&1);switch(c){case "dialog":B("cancel",d);B("close",d);break;case "iframe":case "object":case "embed":B("load",d);break;
case "video":case "audio":for(e=0;e<Ec.length;e++)B(Ec[e],d);break;case "source":B("error",d);break;case "img":case "image":case "link":B("error",d);B("load",d);break;case "details":B("toggle",d);break;case "input":kg(d,f);B("invalid",d);break;case "select":d._wrapperState={wasMultiple:!!f.multiple};B("invalid",d);break;case "textarea":ng(d,f),B("invalid",d)}pe(c,f);e=null;for(var g in f)if(f.hasOwnProperty(g)){var h=f[g];"children"===g?"string"===typeof h?d.textContent!==h&&(!0!==f.suppressHydrationWarning&&
jd(d.textContent,h,a),e=["children",h]):"number"===typeof h&&d.textContent!==""+h&&(!0!==f.suppressHydrationWarning&&jd(d.textContent,h,a),e=["children",""+h]):$b.hasOwnProperty(g)&&null!=h&&"onScroll"===g&&B("scroll",d)}switch(c){case "input":Pc(d);mg(d,f,!0);break;case "textarea":Pc(d);pg(d);break;case "select":case "option":break;default:"function"===typeof f.onClick&&(d.onclick=kd)}d=e;b.updateQueue=d;null!==d&&(b.flags|=4)}else{g=9===e.nodeType?e:e.ownerDocument;"http://www.w3.org/1999/xhtml"===
a&&(a=qg(c));"http://www.w3.org/1999/xhtml"===a?"script"===c?(a=g.createElement("div"),a.innerHTML="<script>\x3c/script>",a=a.removeChild(a.firstChild)):"string"===typeof d.is?a=g.createElement(c,{is:d.is}):(a=g.createElement(c),"select"===c&&(g=a,d.multiple?g.multiple=!0:d.size&&(g.size=d.size))):a=g.createElementNS(a,c);a[Da]=b;a[uc]=d;zk(a,b,!1,!1);b.stateNode=a;a:{g=qe(c,d);switch(c){case "dialog":B("cancel",a);B("close",a);e=d;break;case "iframe":case "object":case "embed":B("load",a);e=d;break;
case "video":case "audio":for(e=0;e<Ec.length;e++)B(Ec[e],a);e=d;break;case "source":B("error",a);e=d;break;case "img":case "image":case "link":B("error",a);B("load",a);e=d;break;case "details":B("toggle",a);e=d;break;case "input":kg(a,d);e=ke(a,d);B("invalid",a);break;case "option":e=d;break;case "select":a._wrapperState={wasMultiple:!!d.multiple};e=E({},d,{value:void 0});B("invalid",a);break;case "textarea":ng(a,d);e=ne(a,d);B("invalid",a);break;default:e=d}pe(c,e);h=e;for(f in h)if(h.hasOwnProperty(f)){var k=
h[f];"style"===f?sg(a,k):"dangerouslySetInnerHTML"===f?(k=k?k.__html:void 0,null!=k&&yi(a,k)):"children"===f?"string"===typeof k?("textarea"!==c||""!==k)&&Fc(a,k):"number"===typeof k&&Fc(a,""+k):"suppressContentEditableWarning"!==f&&"suppressHydrationWarning"!==f&&"autoFocus"!==f&&($b.hasOwnProperty(f)?null!=k&&"onScroll"===f&&B("scroll",a):null!=k&&$d(a,f,k,g))}switch(c){case "input":Pc(a);mg(a,d,!1);break;case "textarea":Pc(a);pg(a);break;case "option":null!=d.value&&a.setAttribute("value",""+Ua(d.value));
break;case "select":a.multiple=!!d.multiple;f=d.value;null!=f?Db(a,!!d.multiple,f,!1):null!=d.defaultValue&&Db(a,!!d.multiple,d.defaultValue,!0);break;default:"function"===typeof e.onClick&&(a.onclick=kd)}switch(c){case "button":case "input":case "select":case "textarea":d=!!d.autoFocus;break a;case "img":d=!0;break a;default:d=!1}}d&&(b.flags|=4)}null!==b.ref&&(b.flags|=512,b.flags|=2097152)}W(b);return null;case 6:if(a&&null!=b.stateNode)Ak(a,b,a.memoizedProps,d);else{if("string"!==typeof d&&null===
b.stateNode)throw Error(m(166));c=ub(xc.current);ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.memoizedProps;d[Da]=b;if(f=d.nodeValue!==c)if(a=la,null!==a)switch(a.tag){case 3:jd(d.nodeValue,c,0!==(a.mode&1));break;case 5:!0!==a.memoizedProps.suppressHydrationWarning&&jd(d.nodeValue,c,0!==(a.mode&1))}f&&(b.flags|=4)}else d=(9===c.nodeType?c:c.ownerDocument).createTextNode(d),d[Da]=b,b.stateNode=d}W(b);return null;case 13:v(F);d=b.memoizedState;if(null===a||null!==a.memoizedState&&null!==a.memoizedState.dehydrated){if(D&&
null!==fa&&0!==(b.mode&1)&&0===(b.flags&128)){for(f=fa;f;)f=Ka(f.nextSibling);Qb();b.flags|=98560;f=!1}else if(f=pd(b),null!==d&&null!==d.dehydrated){if(null===a){if(!f)throw Error(m(318));f=b.memoizedState;f=null!==f?f.dehydrated:null;if(!f)throw Error(m(317));f[Da]=b}else Qb(),0===(b.flags&128)&&(b.memoizedState=null),b.flags|=4;W(b);f=!1}else null!==wa&&(Gf(wa),wa=null),f=!0;if(!f)return b.flags&65536?b:null}if(0!==(b.flags&128))return b.lanes=c,b;d=null!==d;d!==(null!==a&&null!==a.memoizedState)&&
d&&(b.child.flags|=8192,0!==(b.mode&1)&&(null===a||0!==(F.current&1)?0===L&&(L=3):Ef()));null!==b.updateQueue&&(b.flags|=4);W(b);return null;case 4:return Tb(),xi(a,b),null===a&&sc(b.stateNode.containerInfo),W(b),null;case 10:return cf(b.type._context),W(b),null;case 17:return ea(b.type)&&(v(S),v(J)),W(b),null;case 19:v(F);f=b.memoizedState;if(null===f)return W(b),null;d=0!==(b.flags&128);g=f.rendering;if(null===g)if(d)Dc(f,!1);else{if(0!==L||null!==a&&0!==(a.flags&128))for(a=b.child;null!==a;){g=
xd(a);if(null!==g){b.flags|=128;Dc(f,!1);d=g.updateQueue;null!==d&&(b.updateQueue=d,b.flags|=4);b.subtreeFlags=0;d=c;for(c=b.child;null!==c;)f=c,a=d,f.flags&=14680066,g=f.alternate,null===g?(f.childLanes=0,f.lanes=a,f.child=null,f.subtreeFlags=0,f.memoizedProps=null,f.memoizedState=null,f.updateQueue=null,f.dependencies=null,f.stateNode=null):(f.childLanes=g.childLanes,f.lanes=g.lanes,f.child=g.child,f.subtreeFlags=0,f.deletions=null,f.memoizedProps=g.memoizedProps,f.memoizedState=g.memoizedState,
f.updateQueue=g.updateQueue,f.type=g.type,a=g.dependencies,f.dependencies=null===a?null:{lanes:a.lanes,firstContext:a.firstContext}),c=c.sibling;y(F,F.current&1|2);return b.child}a=a.sibling}null!==f.tail&&P()>Hf&&(b.flags|=128,d=!0,Dc(f,!1),b.lanes=4194304)}else{if(!d)if(a=xd(g),null!==a){if(b.flags|=128,d=!0,c=a.updateQueue,null!==c&&(b.updateQueue=c,b.flags|=4),Dc(f,!0),null===f.tail&&"hidden"===f.tailMode&&!g.alternate&&!D)return W(b),null}else 2*P()-f.renderingStartTime>Hf&&1073741824!==c&&(b.flags|=
128,d=!0,Dc(f,!1),b.lanes=4194304);f.isBackwards?(g.sibling=b.child,b.child=g):(c=f.last,null!==c?c.sibling=g:b.child=g,f.last=g)}if(null!==f.tail)return b=f.tail,f.rendering=b,f.tail=b.sibling,f.renderingStartTime=P(),b.sibling=null,c=F.current,y(F,d?c&1|2:c&1),b;W(b);return null;case 22:case 23:return ba=Ga.current,v(Ga),d=null!==b.memoizedState,null!==a&&null!==a.memoizedState!==d&&(b.flags|=8192),d&&0!==(b.mode&1)?0!==(ba&1073741824)&&(W(b),b.subtreeFlags&6&&(b.flags|=8192)):W(b),null;case 24:return null;
case 25:return null}throw Error(m(156,b.tag));}function Bk(a,b,c){Ve(b);switch(b.tag){case 1:return ea(b.type)&&(v(S),v(J)),a=b.flags,a&65536?(b.flags=a&-65537|128,b):null;case 3:return Tb(),v(S),v(J),jf(),a=b.flags,0!==(a&65536)&&0===(a&128)?(b.flags=a&-65537|128,b):null;case 5:return hf(b),null;case 13:v(F);a=b.memoizedState;if(null!==a&&null!==a.dehydrated){if(null===b.alternate)throw Error(m(340));Qb()}a=b.flags;return a&65536?(b.flags=a&-65537|128,b):null;case 19:return v(F),null;case 4:return Tb(),
null;case 10:return cf(b.type._context),null;case 22:case 23:return ba=Ga.current,v(Ga),null;case 24:return null;default:return null}}function Wb(a,b){var c=a.ref;if(null!==c)if("function"===typeof c)try{c(null)}catch(d){G(a,b,d)}else c.current=null}function If(a,b,c){try{c()}catch(d){G(a,b,d)}}function Ck(a,b){Jf=Zc;a=ch();if(Ie(a)){if("selectionStart"in a)var c={start:a.selectionStart,end:a.selectionEnd};else a:{c=(c=a.ownerDocument)&&c.defaultView||window;var d=c.getSelection&&c.getSelection();
if(d&&0!==d.rangeCount){c=d.anchorNode;var e=d.anchorOffset,f=d.focusNode;d=d.focusOffset;try{c.nodeType,f.nodeType}catch(M){c=null;break a}var g=0,h=-1,k=-1,n=0,q=0,u=a,r=null;b:for(;;){for(var p;;){u!==c||0!==e&&3!==u.nodeType||(h=g+e);u!==f||0!==d&&3!==u.nodeType||(k=g+d);3===u.nodeType&&(g+=u.nodeValue.length);if(null===(p=u.firstChild))break;r=u;u=p}for(;;){if(u===a)break b;r===c&&++n===e&&(h=g);r===f&&++q===d&&(k=g);if(null!==(p=u.nextSibling))break;u=r;r=u.parentNode}u=p}c=-1===h||-1===k?null:
{start:h,end:k}}else c=null}c=c||{start:0,end:0}}else c=null;Kf={focusedElem:a,selectionRange:c};Zc=!1;for(l=b;null!==l;)if(b=l,a=b.child,0!==(b.subtreeFlags&1028)&&null!==a)a.return=b,l=a;else for(;null!==l;){b=l;try{var x=b.alternate;if(0!==(b.flags&1024))switch(b.tag){case 0:case 11:case 15:break;case 1:if(null!==x){var v=x.memoizedProps,z=x.memoizedState,w=b.stateNode,A=w.getSnapshotBeforeUpdate(b.elementType===b.type?v:ya(b.type,v),z);w.__reactInternalSnapshotBeforeUpdate=A}break;case 3:var t=
b.stateNode.containerInfo;1===t.nodeType?t.textContent="":9===t.nodeType&&t.documentElement&&t.removeChild(t.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(m(163));}}catch(M){G(b,b.return,M)}a=b.sibling;if(null!==a){a.return=b.return;l=a;break}l=b.return}x=zi;zi=!1;return x}function Gc(a,b,c){var d=b.updateQueue;d=null!==d?d.lastEffect:null;if(null!==d){var e=d=d.next;do{if((e.tag&a)===a){var f=e.destroy;e.destroy=void 0;void 0!==f&&If(b,c,f)}e=e.next}while(e!==d)}}
function Id(a,b){b=b.updateQueue;b=null!==b?b.lastEffect:null;if(null!==b){var c=b=b.next;do{if((c.tag&a)===a){var d=c.create;c.destroy=d()}c=c.next}while(c!==b)}}function Lf(a){var b=a.ref;if(null!==b){var c=a.stateNode;switch(a.tag){case 5:a=c;break;default:a=c}"function"===typeof b?b(a):b.current=a}}function Ai(a){var b=a.alternate;null!==b&&(a.alternate=null,Ai(b));a.child=null;a.deletions=null;a.sibling=null;5===a.tag&&(b=a.stateNode,null!==b&&(delete b[Da],delete b[uc],delete b[Me],delete b[Dk],
delete b[Ek]));a.stateNode=null;a.return=null;a.dependencies=null;a.memoizedProps=null;a.memoizedState=null;a.pendingProps=null;a.stateNode=null;a.updateQueue=null}function Bi(a){return 5===a.tag||3===a.tag||4===a.tag}function Ci(a){a:for(;;){for(;null===a.sibling;){if(null===a.return||Bi(a.return))return null;a=a.return}a.sibling.return=a.return;for(a=a.sibling;5!==a.tag&&6!==a.tag&&18!==a.tag;){if(a.flags&2)continue a;if(null===a.child||4===a.tag)continue a;else a.child.return=a,a=a.child}if(!(a.flags&
2))return a.stateNode}}function Mf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?8===c.nodeType?c.parentNode.insertBefore(a,b):c.insertBefore(a,b):(8===c.nodeType?(b=c.parentNode,b.insertBefore(a,c)):(b=c,b.appendChild(a)),c=c._reactRootContainer,null!==c&&void 0!==c||null!==b.onclick||(b.onclick=kd));else if(4!==d&&(a=a.child,null!==a))for(Mf(a,b,c),a=a.sibling;null!==a;)Mf(a,b,c),a=a.sibling}function Nf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?c.insertBefore(a,b):c.appendChild(a);
else if(4!==d&&(a=a.child,null!==a))for(Nf(a,b,c),a=a.sibling;null!==a;)Nf(a,b,c),a=a.sibling}function jb(a,b,c){for(c=c.child;null!==c;)Di(a,b,c),c=c.sibling}function Di(a,b,c){if(Ca&&"function"===typeof Ca.onCommitFiberUnmount)try{Ca.onCommitFiberUnmount(Uc,c)}catch(h){}switch(c.tag){case 5:X||Wb(c,b);case 6:var d=T,e=za;T=null;jb(a,b,c);T=d;za=e;null!==T&&(za?(a=T,c=c.stateNode,8===a.nodeType?a.parentNode.removeChild(c):a.removeChild(c)):T.removeChild(c.stateNode));break;case 18:null!==T&&(za?
(a=T,c=c.stateNode,8===a.nodeType?Re(a.parentNode,c):1===a.nodeType&&Re(a,c),nc(a)):Re(T,c.stateNode));break;case 4:d=T;e=za;T=c.stateNode.containerInfo;za=!0;jb(a,b,c);T=d;za=e;break;case 0:case 11:case 14:case 15:if(!X&&(d=c.updateQueue,null!==d&&(d=d.lastEffect,null!==d))){e=d=d.next;do{var f=e,g=f.destroy;f=f.tag;void 0!==g&&(0!==(f&2)?If(c,b,g):0!==(f&4)&&If(c,b,g));e=e.next}while(e!==d)}jb(a,b,c);break;case 1:if(!X&&(Wb(c,b),d=c.stateNode,"function"===typeof d.componentWillUnmount))try{d.props=
c.memoizedProps,d.state=c.memoizedState,d.componentWillUnmount()}catch(h){G(c,b,h)}jb(a,b,c);break;case 21:jb(a,b,c);break;case 22:c.mode&1?(X=(d=X)||null!==c.memoizedState,jb(a,b,c),X=d):jb(a,b,c);break;default:jb(a,b,c)}}function Ei(a){var b=a.updateQueue;if(null!==b){a.updateQueue=null;var c=a.stateNode;null===c&&(c=a.stateNode=new Fk);b.forEach(function(b){var d=Gk.bind(null,a,b);c.has(b)||(c.add(b),b.then(d,d))})}}function Aa(a,b,c){c=b.deletions;if(null!==c)for(var d=0;d<c.length;d++){var e=
c[d];try{var f=a,g=b,h=g;a:for(;null!==h;){switch(h.tag){case 5:T=h.stateNode;za=!1;break a;case 3:T=h.stateNode.containerInfo;za=!0;break a;case 4:T=h.stateNode.containerInfo;za=!0;break a}h=h.return}if(null===T)throw Error(m(160));Di(f,g,e);T=null;za=!1;var k=e.alternate;null!==k&&(k.return=null);e.return=null}catch(n){G(e,b,n)}}if(b.subtreeFlags&12854)for(b=b.child;null!==b;)Fi(b,a),b=b.sibling}function Fi(a,b,c){var d=a.alternate;c=a.flags;switch(a.tag){case 0:case 11:case 14:case 15:Aa(b,a);
Ha(a);if(c&4){try{Gc(3,a,a.return),Id(3,a)}catch(I){G(a,a.return,I)}try{Gc(5,a,a.return)}catch(I){G(a,a.return,I)}}break;case 1:Aa(b,a);Ha(a);c&512&&null!==d&&Wb(d,d.return);break;case 5:Aa(b,a);Ha(a);c&512&&null!==d&&Wb(d,d.return);if(a.flags&32){var e=a.stateNode;try{Fc(e,"")}catch(I){G(a,a.return,I)}}if(c&4&&(e=a.stateNode,null!=e)){var f=a.memoizedProps,g=null!==d?d.memoizedProps:f,h=a.type,k=a.updateQueue;a.updateQueue=null;if(null!==k)try{"input"===h&&"radio"===f.type&&null!=f.name&&lg(e,f);
qe(h,g);var n=qe(h,f);for(g=0;g<k.length;g+=2){var q=k[g],u=k[g+1];"style"===q?sg(e,u):"dangerouslySetInnerHTML"===q?yi(e,u):"children"===q?Fc(e,u):$d(e,q,u,n)}switch(h){case "input":le(e,f);break;case "textarea":og(e,f);break;case "select":var r=e._wrapperState.wasMultiple;e._wrapperState.wasMultiple=!!f.multiple;var p=f.value;null!=p?Db(e,!!f.multiple,p,!1):r!==!!f.multiple&&(null!=f.defaultValue?Db(e,!!f.multiple,f.defaultValue,!0):Db(e,!!f.multiple,f.multiple?[]:"",!1))}e[uc]=f}catch(I){G(a,a.return,
I)}}break;case 6:Aa(b,a);Ha(a);if(c&4){if(null===a.stateNode)throw Error(m(162));e=a.stateNode;f=a.memoizedProps;try{e.nodeValue=f}catch(I){G(a,a.return,I)}}break;case 3:Aa(b,a);Ha(a);if(c&4&&null!==d&&d.memoizedState.isDehydrated)try{nc(b.containerInfo)}catch(I){G(a,a.return,I)}break;case 4:Aa(b,a);Ha(a);break;case 13:Aa(b,a);Ha(a);e=a.child;e.flags&8192&&(f=null!==e.memoizedState,e.stateNode.isHidden=f,!f||null!==e.alternate&&null!==e.alternate.memoizedState||(Of=P()));c&4&&Ei(a);break;case 22:q=
null!==d&&null!==d.memoizedState;a.mode&1?(X=(n=X)||q,Aa(b,a),X=n):Aa(b,a);Ha(a);if(c&8192){n=null!==a.memoizedState;if((a.stateNode.isHidden=n)&&!q&&0!==(a.mode&1))for(l=a,q=a.child;null!==q;){for(u=l=q;null!==l;){r=l;p=r.child;switch(r.tag){case 0:case 11:case 14:case 15:Gc(4,r,r.return);break;case 1:Wb(r,r.return);var x=r.stateNode;if("function"===typeof x.componentWillUnmount){c=r;b=r.return;try{d=c,x.props=d.memoizedProps,x.state=d.memoizedState,x.componentWillUnmount()}catch(I){G(c,b,I)}}break;
case 5:Wb(r,r.return);break;case 22:if(null!==r.memoizedState){Gi(u);continue}}null!==p?(p.return=r,l=p):Gi(u)}q=q.sibling}a:for(q=null,u=a;;){if(5===u.tag){if(null===q){q=u;try{e=u.stateNode,n?(f=e.style,"function"===typeof f.setProperty?f.setProperty("display","none","important"):f.display="none"):(h=u.stateNode,k=u.memoizedProps.style,g=void 0!==k&&null!==k&&k.hasOwnProperty("display")?k.display:null,h.style.display=rg("display",g))}catch(I){G(a,a.return,I)}}}else if(6===u.tag){if(null===q)try{u.stateNode.nodeValue=
n?"":u.memoizedProps}catch(I){G(a,a.return,I)}}else if((22!==u.tag&&23!==u.tag||null===u.memoizedState||u===a)&&null!==u.child){u.child.return=u;u=u.child;continue}if(u===a)break a;for(;null===u.sibling;){if(null===u.return||u.return===a)break a;q===u&&(q=null);u=u.return}q===u&&(q=null);u.sibling.return=u.return;u=u.sibling}}break;case 19:Aa(b,a);Ha(a);c&4&&Ei(a);break;case 21:break;default:Aa(b,a),Ha(a)}}function Ha(a){var b=a.flags;if(b&2){try{a:{for(var c=a.return;null!==c;){if(Bi(c)){var d=c;
break a}c=c.return}throw Error(m(160));}switch(d.tag){case 5:var e=d.stateNode;d.flags&32&&(Fc(e,""),d.flags&=-33);var f=Ci(a);Nf(a,f,e);break;case 3:case 4:var g=d.stateNode.containerInfo,h=Ci(a);Mf(a,h,g);break;default:throw Error(m(161));}}catch(k){G(a,a.return,k)}a.flags&=-3}b&4096&&(a.flags&=-4097)}function Hk(a,b,c){l=a;Hi(a,b,c)}function Hi(a,b,c){for(var d=0!==(a.mode&1);null!==l;){var e=l,f=e.child;if(22===e.tag&&d){var g=null!==e.memoizedState||Jd;if(!g){var h=e.alternate,k=null!==h&&null!==
h.memoizedState||X;h=Jd;var n=X;Jd=g;if((X=k)&&!n)for(l=e;null!==l;)g=l,k=g.child,22===g.tag&&null!==g.memoizedState?Ii(e):null!==k?(k.return=g,l=k):Ii(e);for(;null!==f;)l=f,Hi(f,b,c),f=f.sibling;l=e;Jd=h;X=n}Ji(a,b,c)}else 0!==(e.subtreeFlags&8772)&&null!==f?(f.return=e,l=f):Ji(a,b,c)}}function Ji(a,b,c){for(;null!==l;){b=l;if(0!==(b.flags&8772)){c=b.alternate;try{if(0!==(b.flags&8772))switch(b.tag){case 0:case 11:case 15:X||Id(5,b);break;case 1:var d=b.stateNode;if(b.flags&4&&!X)if(null===c)d.componentDidMount();
else{var e=b.elementType===b.type?c.memoizedProps:ya(b.type,c.memoizedProps);d.componentDidUpdate(e,c.memoizedState,d.__reactInternalSnapshotBeforeUpdate)}var f=b.updateQueue;null!==f&&Hh(b,f,d);break;case 3:var g=b.updateQueue;if(null!==g){c=null;if(null!==b.child)switch(b.child.tag){case 5:c=b.child.stateNode;break;case 1:c=b.child.stateNode}Hh(b,g,c)}break;case 5:var h=b.stateNode;if(null===c&&b.flags&4){c=h;var k=b.memoizedProps;switch(b.type){case "button":case "input":case "select":case "textarea":k.autoFocus&&
c.focus();break;case "img":k.src&&(c.src=k.src)}}break;case 6:break;case 4:break;case 12:break;case 13:if(null===b.memoizedState){var n=b.alternate;if(null!==n){var q=n.memoizedState;if(null!==q){var p=q.dehydrated;null!==p&&nc(p)}}}break;case 19:case 17:case 21:case 22:case 23:case 25:break;default:throw Error(m(163));}X||b.flags&512&&Lf(b)}catch(r){G(b,b.return,r)}}if(b===a){l=null;break}c=b.sibling;if(null!==c){c.return=b.return;l=c;break}l=b.return}}function Gi(a){for(;null!==l;){var b=l;if(b===
a){l=null;break}var c=b.sibling;if(null!==c){c.return=b.return;l=c;break}l=b.return}}function Ii(a){for(;null!==l;){var b=l;try{switch(b.tag){case 0:case 11:case 15:var c=b.return;try{Id(4,b)}catch(k){G(b,c,k)}break;case 1:var d=b.stateNode;if("function"===typeof d.componentDidMount){var e=b.return;try{d.componentDidMount()}catch(k){G(b,e,k)}}var f=b.return;try{Lf(b)}catch(k){G(b,f,k)}break;case 5:var g=b.return;try{Lf(b)}catch(k){G(b,g,k)}}}catch(k){G(b,b.return,k)}if(b===a){l=null;break}var h=b.sibling;
if(null!==h){h.return=b.return;l=h;break}l=b.return}}function Hc(){Hf=P()+500}function Z(){return 0!==(p&6)?P():-1!==Kd?Kd:Kd=P()}function hb(a){if(0===(a.mode&1))return 1;if(0!==(p&2)&&0!==U)return U&-U;if(null!==Ik.transition)return 0===Ld&&(Ld=Dg()),Ld;a=z;if(0!==a)return a;a=window.event;a=void 0===a?16:Lg(a.type);return a}function xa(a,b,c,d){if(50<Ic)throw Ic=0,Pf=null,Error(m(185));ic(a,c,d);if(0===(p&2)||a!==O)a===O&&(0===(p&2)&&(Md|=c),4===L&&kb(a,U)),ia(a,d),1===c&&0===p&&0===(b.mode&1)&&
(Hc(),md&&db())}function ia(a,b){var c=a.callbackNode;tj(a,b);var d=Vc(a,a===O?U:0);if(0===d)null!==c&&Ki(c),a.callbackNode=null,a.callbackPriority=0;else if(b=d&-d,a.callbackPriority!==b){null!=c&&Ki(c);if(1===b)0===a.tag?jk(Li.bind(null,a)):wh(Li.bind(null,a)),Jk(function(){0===(p&6)&&db()}),c=null;else{switch(Eg(d)){case 1:c=De;break;case 4:c=Mg;break;case 16:c=ad;break;case 536870912:c=Ng;break;default:c=ad}c=Mi(c,Ni.bind(null,a))}a.callbackPriority=b;a.callbackNode=c}}function Ni(a,b){Kd=-1;
Ld=0;if(0!==(p&6))throw Error(m(327));var c=a.callbackNode;if(Xb()&&a.callbackNode!==c)return null;var d=Vc(a,a===O?U:0);if(0===d)return null;if(0!==(d&30)||0!==(d&a.expiredLanes)||b)b=Nd(a,d);else{b=d;var e=p;p|=2;var f=Oi();if(O!==a||U!==b)Ra=null,Hc(),wb(a,b);do try{Kk();break}catch(h){Pi(a,h)}while(1);af();Od.current=f;p=e;null!==H?b=0:(O=null,U=0,b=L)}if(0!==b){2===b&&(e=ve(a),0!==e&&(d=e,b=Qf(a,e)));if(1===b)throw c=Jc,wb(a,0),kb(a,d),ia(a,P()),c;if(6===b)kb(a,d);else{e=a.current.alternate;
if(0===(d&30)&&!Lk(e)&&(b=Nd(a,d),2===b&&(f=ve(a),0!==f&&(d=f,b=Qf(a,f))),1===b))throw c=Jc,wb(a,0),kb(a,d),ia(a,P()),c;a.finishedWork=e;a.finishedLanes=d;switch(b){case 0:case 1:throw Error(m(345));case 2:xb(a,ja,Ra);break;case 3:kb(a,d);if((d&130023424)===d&&(b=Of+500-P(),10<b)){if(0!==Vc(a,0))break;e=a.suspendedLanes;if((e&d)!==d){Z();a.pingedLanes|=a.suspendedLanes&e;break}a.timeoutHandle=Rf(xb.bind(null,a,ja,Ra),b);break}xb(a,ja,Ra);break;case 4:kb(a,d);if((d&4194240)===d)break;b=a.eventTimes;
for(e=-1;0<d;){var g=31-ta(d);f=1<<g;g=b[g];g>e&&(e=g);d&=~f}d=e;d=P()-d;d=(120>d?120:480>d?480:1080>d?1080:1920>d?1920:3E3>d?3E3:4320>d?4320:1960*Mk(d/1960))-d;if(10<d){a.timeoutHandle=Rf(xb.bind(null,a,ja,Ra),d);break}xb(a,ja,Ra);break;case 5:xb(a,ja,Ra);break;default:throw Error(m(329));}}}ia(a,P());return a.callbackNode===c?Ni.bind(null,a):null}function Qf(a,b){var c=Kc;a.current.memoizedState.isDehydrated&&(wb(a,b).flags|=256);a=Nd(a,b);2!==a&&(b=ja,ja=c,null!==b&&Gf(b));return a}function Gf(a){null===
ja?ja=a:ja.push.apply(ja,a)}function Lk(a){for(var b=a;;){if(b.flags&16384){var c=b.updateQueue;if(null!==c&&(c=c.stores,null!==c))for(var d=0;d<c.length;d++){var e=c[d],f=e.getSnapshot;e=e.value;try{if(!ua(f(),e))return!1}catch(g){return!1}}}c=b.child;if(b.subtreeFlags&16384&&null!==c)c.return=b,b=c;else{if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return!0;b=b.return}b.sibling.return=b.return;b=b.sibling}}return!0}function kb(a,b){b&=~Sf;b&=~Md;a.suspendedLanes|=b;a.pingedLanes&=
~b;for(a=a.expirationTimes;0<b;){var c=31-ta(b),d=1<<c;a[c]=-1;b&=~d}}function Li(a){if(0!==(p&6))throw Error(m(327));Xb();var b=Vc(a,0);if(0===(b&1))return ia(a,P()),null;var c=Nd(a,b);if(0!==a.tag&&2===c){var d=ve(a);0!==d&&(b=d,c=Qf(a,d))}if(1===c)throw c=Jc,wb(a,0),kb(a,b),ia(a,P()),c;if(6===c)throw Error(m(345));a.finishedWork=a.current.alternate;a.finishedLanes=b;xb(a,ja,Ra);ia(a,P());return null}function Tf(a,b){var c=p;p|=1;try{return a(b)}finally{p=c,0===p&&(Hc(),md&&db())}}function yb(a){null!==
lb&&0===lb.tag&&0===(p&6)&&Xb();var b=p;p|=1;var c=ca.transition,d=z;try{if(ca.transition=null,z=1,a)return a()}finally{z=d,ca.transition=c,p=b,0===(p&6)&&db()}}function wb(a,b){a.finishedWork=null;a.finishedLanes=0;var c=a.timeoutHandle;-1!==c&&(a.timeoutHandle=-1,Nk(c));if(null!==H)for(c=H.return;null!==c;){var d=c;Ve(d);switch(d.tag){case 1:d=d.type.childContextTypes;null!==d&&void 0!==d&&(v(S),v(J));break;case 3:Tb();v(S);v(J);jf();break;case 5:hf(d);break;case 4:Tb();break;case 13:v(F);break;
case 19:v(F);break;case 10:cf(d.type._context);break;case 22:case 23:ba=Ga.current,v(Ga)}c=c.return}O=a;H=a=eb(a.current,null);U=ba=b;L=0;Jc=null;Sf=Md=ra=0;ja=Kc=null;if(null!==tb){for(b=0;b<tb.length;b++)if(c=tb[b],d=c.interleaved,null!==d){c.interleaved=null;var e=d.next,f=c.pending;if(null!==f){var g=f.next;f.next=e;d.next=g}c.pending=d}tb=null}return a}function Pi(a,b){do{var c=H;try{af();yd.current=zd;if(Ad){for(var d=C.memoizedState;null!==d;){var e=d.queue;null!==e&&(e.pending=null);d=d.next}Ad=
!1}vb=0;N=K=C=null;zc=!1;Ac=0;Uf.current=null;if(null===c||null===c.return){L=1;Jc=b;H=null;break}a:{var f=a,g=c.return,h=c,k=b;b=U;h.flags|=32768;if(null!==k&&"object"===typeof k&&"function"===typeof k.then){var n=k,l=h,p=l.tag;if(0===(l.mode&1)&&(0===p||11===p||15===p)){var r=l.alternate;r?(l.updateQueue=r.updateQueue,l.memoizedState=r.memoizedState,l.lanes=r.lanes):(l.updateQueue=null,l.memoizedState=null)}var v=ji(g);if(null!==v){v.flags&=-257;ki(v,g,h,f,b);v.mode&1&&ii(f,n,b);b=v;k=n;var x=b.updateQueue;
if(null===x){var z=new Set;z.add(k);b.updateQueue=z}else x.add(k);break a}else{if(0===(b&1)){ii(f,n,b);Ef();break a}k=Error(m(426))}}else if(D&&h.mode&1){var y=ji(g);if(null!==y){0===(y.flags&65536)&&(y.flags|=256);ki(y,g,h,f,b);Ye(Ub(k,h));break a}}f=k=Ub(k,h);4!==L&&(L=2);null===Kc?Kc=[f]:Kc.push(f);f=g;do{switch(f.tag){case 3:f.flags|=65536;b&=-b;f.lanes|=b;var w=gi(f,k,b);Gh(f,w);break a;case 1:h=k;var A=f.type,t=f.stateNode;if(0===(f.flags&128)&&("function"===typeof A.getDerivedStateFromError||
null!==t&&"function"===typeof t.componentDidCatch&&(null===ib||!ib.has(t)))){f.flags|=65536;b&=-b;f.lanes|=b;var B=hi(f,h,b);Gh(f,B);break a}}f=f.return}while(null!==f)}Qi(c)}catch(ma){b=ma;H===c&&null!==c&&(H=c=c.return);continue}break}while(1)}function Oi(){var a=Od.current;Od.current=zd;return null===a?zd:a}function Ef(){if(0===L||3===L||2===L)L=4;null===O||0===(ra&268435455)&&0===(Md&268435455)||kb(O,U)}function Nd(a,b){var c=p;p|=2;var d=Oi();if(O!==a||U!==b)Ra=null,wb(a,b);do try{Ok();break}catch(e){Pi(a,
e)}while(1);af();p=c;Od.current=d;if(null!==H)throw Error(m(261));O=null;U=0;return L}function Ok(){for(;null!==H;)Ri(H)}function Kk(){for(;null!==H&&!Pk();)Ri(H)}function Ri(a){var b=Qk(a.alternate,a,ba);a.memoizedProps=a.pendingProps;null===b?Qi(a):H=b;Uf.current=null}function Qi(a){var b=a;do{var c=b.alternate;a=b.return;if(0===(b.flags&32768)){if(c=xk(c,b,ba),null!==c){H=c;return}}else{c=Bk(c,b);if(null!==c){c.flags&=32767;H=c;return}if(null!==a)a.flags|=32768,a.subtreeFlags=0,a.deletions=null;
else{L=6;H=null;return}}b=b.sibling;if(null!==b){H=b;return}H=b=a}while(null!==b);0===L&&(L=5)}function xb(a,b,c){var d=z,e=ca.transition;try{ca.transition=null,z=1,Rk(a,b,c,d)}finally{ca.transition=e,z=d}return null}function Rk(a,b,c,d){do Xb();while(null!==lb);if(0!==(p&6))throw Error(m(327));c=a.finishedWork;var e=a.finishedLanes;if(null===c)return null;a.finishedWork=null;a.finishedLanes=0;if(c===a.current)throw Error(m(177));a.callbackNode=null;a.callbackPriority=0;var f=c.lanes|c.childLanes;
uj(a,f);a===O&&(H=O=null,U=0);0===(c.subtreeFlags&2064)&&0===(c.flags&2064)||Pd||(Pd=!0,Mi(ad,function(){Xb();return null}));f=0!==(c.flags&15990);if(0!==(c.subtreeFlags&15990)||f){f=ca.transition;ca.transition=null;var g=z;z=1;var h=p;p|=4;Uf.current=null;Ck(a,c);Fi(c,a);Tj(Kf);Zc=!!Jf;Kf=Jf=null;a.current=c;Hk(c,a,e);Sk();p=h;z=g;ca.transition=f}else a.current=c;Pd&&(Pd=!1,lb=a,Qd=e);f=a.pendingLanes;0===f&&(ib=null);oj(c.stateNode,d);ia(a,P());if(null!==b)for(d=a.onRecoverableError,c=0;c<b.length;c++)e=
b[c],d(e.value,{componentStack:e.stack,digest:e.digest});if(Ed)throw Ed=!1,a=xf,xf=null,a;0!==(Qd&1)&&0!==a.tag&&Xb();f=a.pendingLanes;0!==(f&1)?a===Pf?Ic++:(Ic=0,Pf=a):Ic=0;db();return null}function Xb(){if(null!==lb){var a=Eg(Qd),b=ca.transition,c=z;try{ca.transition=null;z=16>a?16:a;if(null===lb)var d=!1;else{a=lb;lb=null;Qd=0;if(0!==(p&6))throw Error(m(331));var e=p;p|=4;for(l=a.current;null!==l;){var f=l,g=f.child;if(0!==(l.flags&16)){var h=f.deletions;if(null!==h){for(var k=0;k<h.length;k++){var n=
h[k];for(l=n;null!==l;){var q=l;switch(q.tag){case 0:case 11:case 15:Gc(8,q,f)}var u=q.child;if(null!==u)u.return=q,l=u;else for(;null!==l;){q=l;var r=q.sibling,v=q.return;Ai(q);if(q===n){l=null;break}if(null!==r){r.return=v;l=r;break}l=v}}}var x=f.alternate;if(null!==x){var y=x.child;if(null!==y){x.child=null;do{var C=y.sibling;y.sibling=null;y=C}while(null!==y)}}l=f}}if(0!==(f.subtreeFlags&2064)&&null!==g)g.return=f,l=g;else b:for(;null!==l;){f=l;if(0!==(f.flags&2048))switch(f.tag){case 0:case 11:case 15:Gc(9,
f,f.return)}var w=f.sibling;if(null!==w){w.return=f.return;l=w;break b}l=f.return}}var A=a.current;for(l=A;null!==l;){g=l;var t=g.child;if(0!==(g.subtreeFlags&2064)&&null!==t)t.return=g,l=t;else b:for(g=A;null!==l;){h=l;if(0!==(h.flags&2048))try{switch(h.tag){case 0:case 11:case 15:Id(9,h)}}catch(ma){G(h,h.return,ma)}if(h===g){l=null;break b}var B=h.sibling;if(null!==B){B.return=h.return;l=B;break b}l=h.return}}p=e;db();if(Ca&&"function"===typeof Ca.onPostCommitFiberRoot)try{Ca.onPostCommitFiberRoot(Uc,
a)}catch(ma){}d=!0}return d}finally{z=c,ca.transition=b}}return!1}function Si(a,b,c){b=Ub(c,b);b=gi(a,b,1);a=fb(a,b,1);b=Z();null!==a&&(ic(a,1,b),ia(a,b))}function G(a,b,c){if(3===a.tag)Si(a,a,c);else for(;null!==b;){if(3===b.tag){Si(b,a,c);break}else if(1===b.tag){var d=b.stateNode;if("function"===typeof b.type.getDerivedStateFromError||"function"===typeof d.componentDidCatch&&(null===ib||!ib.has(d))){a=Ub(c,a);a=hi(b,a,1);b=fb(b,a,1);a=Z();null!==b&&(ic(b,1,a),ia(b,a));break}}b=b.return}}function sk(a,
b,c){var d=a.pingCache;null!==d&&d.delete(b);b=Z();a.pingedLanes|=a.suspendedLanes&c;O===a&&(U&c)===c&&(4===L||3===L&&(U&130023424)===U&&500>P()-Of?wb(a,0):Sf|=c);ia(a,b)}function Ti(a,b){0===b&&(0===(a.mode&1)?b=1:(b=Rd,Rd<<=1,0===(Rd&130023424)&&(Rd=4194304)));var c=Z();a=Oa(a,b);null!==a&&(ic(a,b,c),ia(a,c))}function vk(a){var b=a.memoizedState,c=0;null!==b&&(c=b.retryLane);Ti(a,c)}function Gk(a,b){var c=0;switch(a.tag){case 13:var d=a.stateNode;var e=a.memoizedState;null!==e&&(c=e.retryLane);
break;case 19:d=a.stateNode;break;default:throw Error(m(314));}null!==d&&d.delete(b);Ti(a,c)}function Mi(a,b){return xh(a,b)}function Tk(a,b,c,d){this.tag=a;this.key=c;this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null;this.index=0;this.ref=null;this.pendingProps=b;this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null;this.mode=d;this.subtreeFlags=this.flags=0;this.deletions=null;this.childLanes=this.lanes=0;this.alternate=null}function yf(a){a=
a.prototype;return!(!a||!a.isReactComponent)}function Uk(a){if("function"===typeof a)return yf(a)?1:0;if(void 0!==a&&null!==a){a=a.$$typeof;if(a===ie)return 11;if(a===je)return 14}return 2}function eb(a,b){var c=a.alternate;null===c?(c=pa(a.tag,b,a.key,a.mode),c.elementType=a.elementType,c.type=a.type,c.stateNode=a.stateNode,c.alternate=a,a.alternate=c):(c.pendingProps=b,c.type=a.type,c.flags=0,c.subtreeFlags=0,c.deletions=null);c.flags=a.flags&14680064;c.childLanes=a.childLanes;c.lanes=a.lanes;c.child=
a.child;c.memoizedProps=a.memoizedProps;c.memoizedState=a.memoizedState;c.updateQueue=a.updateQueue;b=a.dependencies;c.dependencies=null===b?null:{lanes:b.lanes,firstContext:b.firstContext};c.sibling=a.sibling;c.index=a.index;c.ref=a.ref;return c}function rd(a,b,c,d,e,f){var g=2;d=a;if("function"===typeof a)yf(a)&&(g=1);else if("string"===typeof a)g=5;else a:switch(a){case Bb:return sb(c.children,e,f,b);case fe:g=8;e|=8;break;case ee:return a=pa(12,c,b,e|2),a.elementType=ee,a.lanes=f,a;case ge:return a=
pa(13,c,b,e),a.elementType=ge,a.lanes=f,a;case he:return a=pa(19,c,b,e),a.elementType=he,a.lanes=f,a;case Ui:return Gd(c,e,f,b);default:if("object"===typeof a&&null!==a)switch(a.$$typeof){case hg:g=10;break a;case gg:g=9;break a;case ie:g=11;break a;case je:g=14;break a;case Ta:g=16;d=null;break a}throw Error(m(130,null==a?a:typeof a,""));}b=pa(g,c,b,e);b.elementType=a;b.type=d;b.lanes=f;return b}function sb(a,b,c,d){a=pa(7,a,d,b);a.lanes=c;return a}function Gd(a,b,c,d){a=pa(22,a,d,b);a.elementType=
Ui;a.lanes=c;a.stateNode={isHidden:!1};return a}function Ze(a,b,c){a=pa(6,a,null,b);a.lanes=c;return a}function $e(a,b,c){b=pa(4,null!==a.children?a.children:[],a.key,b);b.lanes=c;b.stateNode={containerInfo:a.containerInfo,pendingChildren:null,implementation:a.implementation};return b}function Vk(a,b,c,d,e){this.tag=b;this.containerInfo=a;this.finishedWork=this.pingCache=this.current=this.pendingChildren=null;this.timeoutHandle=-1;this.callbackNode=this.pendingContext=this.context=null;this.callbackPriority=
0;this.eventTimes=we(0);this.expirationTimes=we(-1);this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0;this.entanglements=we(0);this.identifierPrefix=d;this.onRecoverableError=e;this.mutableSourceEagerHydrationData=null}function Vf(a,b,c,d,e,f,g,h,k,l){a=new Vk(a,b,c,h,k);1===b?(b=1,!0===f&&(b|=8)):b=0;f=pa(3,null,null,b);a.current=f;f.stateNode=a;f.memoizedState={element:d,isDehydrated:c,cache:null,transitions:null,
pendingSuspenseBoundaries:null};ff(f);return a}function Wk(a,b,c){var d=3<arguments.length&&void 0!==arguments[3]?arguments[3]:null;return{$$typeof:Cb,key:null==d?null:""+d,children:a,containerInfo:b,implementation:c}}function Vi(a){if(!a)return cb;a=a._reactInternals;a:{if(nb(a)!==a||1!==a.tag)throw Error(m(170));var b=a;do{switch(b.tag){case 3:b=b.stateNode.context;break a;case 1:if(ea(b.type)){b=b.stateNode.__reactInternalMemoizedMergedChildContext;break a}}b=b.return}while(null!==b);throw Error(m(171));
}if(1===a.tag){var c=a.type;if(ea(c))return uh(a,c,b)}return b}function Wi(a,b,c,d,e,f,g,h,k,l){a=Vf(c,d,!0,a,e,f,g,h,k);a.context=Vi(null);c=a.current;d=Z();e=hb(c);f=Pa(d,e);f.callback=void 0!==b&&null!==b?b:null;fb(c,f,e);a.current.lanes=e;ic(a,e,d);ia(a,d);return a}function Sd(a,b,c,d){var e=b.current,f=Z(),g=hb(e);c=Vi(c);null===b.context?b.context=c:b.pendingContext=c;b=Pa(f,g);b.payload={element:a};d=void 0===d?null:d;null!==d&&(b.callback=d);a=fb(e,b,g);null!==a&&(xa(a,e,g,f),vd(a,e,g));return g}
function Td(a){a=a.current;if(!a.child)return null;switch(a.child.tag){case 5:return a.child.stateNode;default:return a.child.stateNode}}function Xi(a,b){a=a.memoizedState;if(null!==a&&null!==a.dehydrated){var c=a.retryLane;a.retryLane=0!==c&&c<b?c:b}}function Wf(a,b){Xi(a,b);(a=a.alternate)&&Xi(a,b)}function Xk(a){a=Bg(a);return null===a?null:a.stateNode}function Yk(a){return null}function Xf(a){this._internalRoot=a}function Ud(a){this._internalRoot=a}function Yf(a){return!(!a||1!==a.nodeType&&9!==
a.nodeType&&11!==a.nodeType)}function Vd(a){return!(!a||1!==a.nodeType&&9!==a.nodeType&&11!==a.nodeType&&(8!==a.nodeType||" react-mount-point-unstable "!==a.nodeValue))}function Yi(){}function Zk(a,b,c,d,e){if(e){if("function"===typeof d){var f=d;d=function(){var a=Td(g);f.call(a)}}var g=Wi(b,d,a,0,null,!1,!1,"",Yi);a._reactRootContainer=g;a[Ja]=g.current;sc(8===a.nodeType?a.parentNode:a);yb();return g}for(;e=a.lastChild;)a.removeChild(e);if("function"===typeof d){var h=d;d=function(){var a=Td(k);
h.call(a)}}var k=Vf(a,0,!1,null,null,!1,!1,"",Yi);a._reactRootContainer=k;a[Ja]=k.current;sc(8===a.nodeType?a.parentNode:a);yb(function(){Sd(b,k,c,d)});return k}function Wd(a,b,c,d,e){var f=c._reactRootContainer;if(f){var g=f;if("function"===typeof e){var h=e;e=function(){var a=Td(g);h.call(a)}}Sd(b,g,a,e)}else g=Zk(c,b,a,e,d);return Td(g)}var cg=new Set,$b={},Ia=!("undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement),Zd=Object.prototype.hasOwnProperty,
cj=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,eg={},dg={},R={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(a){R[a]=
new Y(a,0,!1,a,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(a){var b=a[0];R[b]=new Y(b,1,!1,a[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(a){R[a]=new Y(a,2,!1,a.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(a){R[a]=new Y(a,2,!1,a,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(a){R[a]=
new Y(a,3,!1,a.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(a){R[a]=new Y(a,3,!0,a,null,!1,!1)});["capture","download"].forEach(function(a){R[a]=new Y(a,4,!1,a,null,!1,!1)});["cols","rows","size","span"].forEach(function(a){R[a]=new Y(a,6,!1,a,null,!1,!1)});["rowSpan","start"].forEach(function(a){R[a]=new Y(a,5,!1,a.toLowerCase(),null,!1,!1)});var Zf=/[\-:]([a-z])/g,$f=function(a){return a[1].toUpperCase()};"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(a){var b=
a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(a){var b=a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(a){var b=a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(a){R[a]=new Y(a,1,!1,a.toLowerCase(),null,!1,!1)});R.xlinkHref=new Y("xlinkHref",
1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(a){R[a]=new Y(a,1,!1,a.toLowerCase(),null,!0,!0)});var Sa=zb.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,sd=Symbol.for("react.element"),Cb=Symbol.for("react.portal"),Bb=Symbol.for("react.fragment"),fe=Symbol.for("react.strict_mode"),ee=Symbol.for("react.profiler"),hg=Symbol.for("react.provider"),gg=Symbol.for("react.context"),ie=Symbol.for("react.forward_ref"),ge=Symbol.for("react.suspense"),
he=Symbol.for("react.suspense_list"),je=Symbol.for("react.memo"),Ta=Symbol.for("react.lazy");Symbol.for("react.scope");Symbol.for("react.debug_trace_mode");var Ui=Symbol.for("react.offscreen");Symbol.for("react.legacy_hidden");Symbol.for("react.cache");Symbol.for("react.tracing_marker");var fg=Symbol.iterator,E=Object.assign,ae,ce=!1,cc=Array.isArray,Xd,yi=function(a){return"undefined"!==typeof MSApp&&MSApp.execUnsafeLocalFunction?function(b,c,d,e){MSApp.execUnsafeLocalFunction(function(){return a(b,
c,d,e)})}:a}(function(a,b){if("http://www.w3.org/2000/svg"!==a.namespaceURI||"innerHTML"in a)a.innerHTML=b;else{Xd=Xd||document.createElement("div");Xd.innerHTML="<svg>"+b.valueOf().toString()+"</svg>";for(b=Xd.firstChild;a.firstChild;)a.removeChild(a.firstChild);for(;b.firstChild;)a.appendChild(b.firstChild)}}),Fc=function(a,b){if(b){var c=a.firstChild;if(c&&c===a.lastChild&&3===c.nodeType){c.nodeValue=b;return}}a.textContent=b},dc={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,
borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,
strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},$k=["Webkit","ms","Moz","O"];Object.keys(dc).forEach(function(a){$k.forEach(function(b){b=b+a.charAt(0).toUpperCase()+a.substring(1);dc[b]=dc[a]})});var ij=E({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),ze=null,se=null,Eb=null,Fb=null,xg=function(a,b){return a(b)},yg=function(){},te=!1,Oe=!1;if(Ia)try{var Lc={};Object.defineProperty(Lc,
"passive",{get:function(){Oe=!0}});window.addEventListener("test",Lc,Lc);window.removeEventListener("test",Lc,Lc)}catch(a){Oe=!1}var kj=function(a,b,c,d,e,f,g,h,k){var l=Array.prototype.slice.call(arguments,3);try{b.apply(c,l)}catch(q){this.onError(q)}},gc=!1,Sc=null,Tc=!1,ue=null,lj={onError:function(a){gc=!0;Sc=a}},Ba=zb.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler,Jg=Ba.unstable_scheduleCallback,Kg=Ba.unstable_NormalPriority,xh=Jg,Ki=Ba.unstable_cancelCallback,Pk=Ba.unstable_shouldYield,
Sk=Ba.unstable_requestPaint,P=Ba.unstable_now,Dj=Ba.unstable_getCurrentPriorityLevel,De=Ba.unstable_ImmediatePriority,Mg=Ba.unstable_UserBlockingPriority,ad=Kg,Ej=Ba.unstable_LowPriority,Ng=Ba.unstable_IdlePriority,Uc=null,Ca=null,ta=Math.clz32?Math.clz32:pj,qj=Math.log,rj=Math.LN2,Wc=64,Rd=4194304,z=0,Ae=!1,Yc=[],Va=null,Wa=null,Xa=null,jc=new Map,kc=new Map,Ya=[],Bj="mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset submit".split(" "),
Gb=Sa.ReactCurrentBatchConfig,Zc=!0,$c=null,Za=null,Ee=null,bd=null,Yb={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(a){return a.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},He=ka(Yb),Mc=E({},Yb,{view:0,detail:0}),ak=ka(Mc),ag,bg,Nc,Yd=E({},Mc,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:Fe,button:0,buttons:0,relatedTarget:function(a){return void 0===a.relatedTarget?a.fromElement===a.srcElement?a.toElement:a.fromElement:
a.relatedTarget},movementX:function(a){if("movementX"in a)return a.movementX;a!==Nc&&(Nc&&"mousemove"===a.type?(ag=a.screenX-Nc.screenX,bg=a.screenY-Nc.screenY):bg=ag=0,Nc=a);return ag},movementY:function(a){return"movementY"in a?a.movementY:bg}}),ih=ka(Yd),al=E({},Yd,{dataTransfer:0}),Wj=ka(al),bl=E({},Mc,{relatedTarget:0}),Pe=ka(bl),cl=E({},Yb,{animationName:0,elapsedTime:0,pseudoElement:0}),Yj=ka(cl),dl=E({},Yb,{clipboardData:function(a){return"clipboardData"in a?a.clipboardData:window.clipboardData}}),
ck=ka(dl),el=E({},Yb,{data:0}),qh=ka(el),fk=qh,fl={Esc:"Escape",Spacebar:" ",Left:"ArrowLeft",Up:"ArrowUp",Right:"ArrowRight",Down:"ArrowDown",Del:"Delete",Win:"OS",Menu:"ContextMenu",Apps:"ContextMenu",Scroll:"ScrollLock",MozPrintableKey:"Unidentified"},gl={8:"Backspace",9:"Tab",12:"Clear",13:"Enter",16:"Shift",17:"Control",18:"Alt",19:"Pause",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",45:"Insert",46:"Delete",
112:"F1",113:"F2",114:"F3",115:"F4",116:"F5",117:"F6",118:"F7",119:"F8",120:"F9",121:"F10",122:"F11",123:"F12",144:"NumLock",145:"ScrollLock",224:"Meta"},Gj={Alt:"altKey",Control:"ctrlKey",Meta:"metaKey",Shift:"shiftKey"},hl=E({},Mc,{key:function(a){if(a.key){var b=fl[a.key]||a.key;if("Unidentified"!==b)return b}return"keypress"===a.type?(a=cd(a),13===a?"Enter":String.fromCharCode(a)):"keydown"===a.type||"keyup"===a.type?gl[a.keyCode]||"Unidentified":""},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,
metaKey:0,repeat:0,locale:0,getModifierState:Fe,charCode:function(a){return"keypress"===a.type?cd(a):0},keyCode:function(a){return"keydown"===a.type||"keyup"===a.type?a.keyCode:0},which:function(a){return"keypress"===a.type?cd(a):"keydown"===a.type||"keyup"===a.type?a.keyCode:0}}),Vj=ka(hl),il=E({},Yd,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0}),nh=ka(il),jl=E({},Mc,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,
ctrlKey:0,shiftKey:0,getModifierState:Fe}),Xj=ka(jl),kl=E({},Yb,{propertyName:0,elapsedTime:0,pseudoElement:0}),Zj=ka(kl),ll=E({},Yd,{deltaX:function(a){return"deltaX"in a?a.deltaX:"wheelDeltaX"in a?-a.wheelDeltaX:0},deltaY:function(a){return"deltaY"in a?a.deltaY:"wheelDeltaY"in a?-a.wheelDeltaY:"wheelDelta"in a?-a.wheelDelta:0},deltaZ:0,deltaMode:0}),bk=ka(ll),Hj=[9,13,27,32],Ge=Ia&&"CompositionEvent"in window,Oc=null;Ia&&"documentMode"in document&&(Oc=document.documentMode);var ek=Ia&&"TextEvent"in
window&&!Oc,Ug=Ia&&(!Ge||Oc&&8<Oc&&11>=Oc),Tg=String.fromCharCode(32),Sg=!1,Hb=!1,Kj={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0},oc=null,pc=null,ph=!1;Ia&&(ph=Lj("input")&&(!document.documentMode||9<document.documentMode));var ua="function"===typeof Object.is?Object.is:Sj,dk=Ia&&"documentMode"in document&&11>=document.documentMode,Jb=null,Ke=null,rc=null,Je=!1,Kb={animationend:gd("Animation","AnimationEnd"),
animationiteration:gd("Animation","AnimationIteration"),animationstart:gd("Animation","AnimationStart"),transitionend:gd("Transition","TransitionEnd")},Le={},eh={};Ia&&(eh=document.createElement("div").style,"AnimationEvent"in window||(delete Kb.animationend.animation,delete Kb.animationiteration.animation,delete Kb.animationstart.animation),"TransitionEvent"in window||delete Kb.transitionend.transition);var jh=hd("animationend"),kh=hd("animationiteration"),lh=hd("animationstart"),mh=hd("transitionend"),
fh=new Map,Zi="abort auxClick cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");
(function(){for(var a=0;a<Zi.length;a++){var b=Zi[a],c=b.toLowerCase();b=b[0].toUpperCase()+b.slice(1);$a(c,"on"+b)}$a(jh,"onAnimationEnd");$a(kh,"onAnimationIteration");$a(lh,"onAnimationStart");$a("dblclick","onDoubleClick");$a("focusin","onFocus");$a("focusout","onBlur");$a(mh,"onTransitionEnd")})();Ab("onMouseEnter",["mouseout","mouseover"]);Ab("onMouseLeave",["mouseout","mouseover"]);Ab("onPointerEnter",["pointerout","pointerover"]);Ab("onPointerLeave",["pointerout","pointerover"]);mb("onChange",
"change click focusin focusout input keydown keyup selectionchange".split(" "));mb("onSelect","focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange".split(" "));mb("onBeforeInput",["compositionend","keypress","textInput","paste"]);mb("onCompositionEnd","compositionend focusout keydown keypress keyup mousedown".split(" "));mb("onCompositionStart","compositionstart focusout keydown keypress keyup mousedown".split(" "));mb("onCompositionUpdate","compositionupdate focusout keydown keypress keyup mousedown".split(" "));
var Ec="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),Uj=new Set("cancel close invalid load scroll toggle".split(" ").concat(Ec)),id="_reactListening"+Math.random().toString(36).slice(2),gk=/\r\n?/g,hk=/\u0000|\uFFFD/g,Jf=null,Kf=null,Rf="function"===typeof setTimeout?setTimeout:void 0,Nk="function"===typeof clearTimeout?
clearTimeout:void 0,$i="function"===typeof Promise?Promise:void 0,Jk="function"===typeof queueMicrotask?queueMicrotask:"undefined"!==typeof $i?function(a){return $i.resolve(null).then(a).catch(ik)}:Rf,Zb=Math.random().toString(36).slice(2),Da="__reactFiber$"+Zb,uc="__reactProps$"+Zb,Ja="__reactContainer$"+Zb,Me="__reactEvents$"+Zb,Dk="__reactListeners$"+Zb,Ek="__reactHandles$"+Zb,Se=[],Mb=-1,cb={},J=bb(cb),S=bb(!1),pb=cb,La=null,md=!1,Te=!1,Ob=[],Pb=0,od=null,nd=0,na=[],oa=0,rb=null,Ma=1,Na="",la=
null,fa=null,D=!1,wa=null,Ik=Sa.ReactCurrentBatchConfig,Vb=Dh(!0),li=Dh(!1),ud=bb(null),td=null,Rb=null,bf=null,tb=null,kk=Oa,gb=!1,wc={},Ea=bb(wc),yc=bb(wc),xc=bb(wc),F=bb(0),kf=[],yd=Sa.ReactCurrentDispatcher,sf=Sa.ReactCurrentBatchConfig,vb=0,C=null,K=null,N=null,Ad=!1,zc=!1,Ac=0,ml=0,zd={readContext:qa,useCallback:V,useContext:V,useEffect:V,useImperativeHandle:V,useInsertionEffect:V,useLayoutEffect:V,useMemo:V,useReducer:V,useRef:V,useState:V,useDebugValue:V,useDeferredValue:V,useTransition:V,
useMutableSource:V,useSyncExternalStore:V,useId:V,unstable_isNewReconciler:!1},lk={readContext:qa,useCallback:function(a,b){Fa().memoizedState=[a,void 0===b?null:b];return a},useContext:qa,useEffect:Sh,useImperativeHandle:function(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Bd(4194308,4,Vh.bind(null,b,a),c)},useLayoutEffect:function(a,b){return Bd(4194308,4,a,b)},useInsertionEffect:function(a,b){return Bd(4,2,a,b)},useMemo:function(a,b){var c=Fa();b=void 0===b?null:b;a=a();c.memoizedState=
[a,b];return a},useReducer:function(a,b,c){var d=Fa();b=void 0!==c?c(b):b;d.memoizedState=d.baseState=b;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:a,lastRenderedState:b};d.queue=a;a=a.dispatch=qk.bind(null,C,a);return[d.memoizedState,a]},useRef:function(a){var b=Fa();a={current:a};return b.memoizedState=a},useState:Qh,useDebugValue:rf,useDeferredValue:function(a){return Fa().memoizedState=a},useTransition:function(){var a=Qh(!1),b=a[0];a=pk.bind(null,a[1]);Fa().memoizedState=
a;return[b,a]},useMutableSource:function(a,b,c){},useSyncExternalStore:function(a,b,c){var d=C,e=Fa();if(D){if(void 0===c)throw Error(m(407));c=c()}else{c=b();if(null===O)throw Error(m(349));0!==(vb&30)||Nh(d,b,c)}e.memoizedState=c;var f={value:c,getSnapshot:b};e.queue=f;Sh(Lh.bind(null,d,f,a),[a]);d.flags|=2048;Cc(9,Mh.bind(null,d,f,c,b),void 0,null);return c},useId:function(){var a=Fa(),b=O.identifierPrefix;if(D){var c=Na;var d=Ma;c=(d&~(1<<32-ta(d)-1)).toString(32)+c;b=":"+b+"R"+c;c=Ac++;0<c&&
(b+="H"+c.toString(32));b+=":"}else c=ml++,b=":"+b+"r"+c.toString(32)+":";return a.memoizedState=b},unstable_isNewReconciler:!1},mk={readContext:qa,useCallback:Xh,useContext:qa,useEffect:qf,useImperativeHandle:Wh,useInsertionEffect:Th,useLayoutEffect:Uh,useMemo:Yh,useReducer:of,useRef:Rh,useState:function(a){return of(Bc)},useDebugValue:rf,useDeferredValue:function(a){var b=sa();return Zh(b,K.memoizedState,a)},useTransition:function(){var a=of(Bc)[0],b=sa().memoizedState;return[a,b]},useMutableSource:Jh,
useSyncExternalStore:Kh,useId:$h,unstable_isNewReconciler:!1},nk={readContext:qa,useCallback:Xh,useContext:qa,useEffect:qf,useImperativeHandle:Wh,useInsertionEffect:Th,useLayoutEffect:Uh,useMemo:Yh,useReducer:pf,useRef:Rh,useState:function(a){return pf(Bc)},useDebugValue:rf,useDeferredValue:function(a){var b=sa();return null===K?b.memoizedState=a:Zh(b,K.memoizedState,a)},useTransition:function(){var a=pf(Bc)[0],b=sa().memoizedState;return[a,b]},useMutableSource:Jh,useSyncExternalStore:Kh,useId:$h,
unstable_isNewReconciler:!1},Dd={isMounted:function(a){return(a=a._reactInternals)?nb(a)===a:!1},enqueueSetState:function(a,b,c){a=a._reactInternals;var d=Z(),e=hb(a),f=Pa(d,e);f.payload=b;void 0!==c&&null!==c&&(f.callback=c);b=fb(a,f,e);null!==b&&(xa(b,a,e,d),vd(b,a,e))},enqueueReplaceState:function(a,b,c){a=a._reactInternals;var d=Z(),e=hb(a),f=Pa(d,e);f.tag=1;f.payload=b;void 0!==c&&null!==c&&(f.callback=c);b=fb(a,f,e);null!==b&&(xa(b,a,e,d),vd(b,a,e))},enqueueForceUpdate:function(a,b){a=a._reactInternals;
var c=Z(),d=hb(a),e=Pa(c,d);e.tag=2;void 0!==b&&null!==b&&(e.callback=b);b=fb(a,e,d);null!==b&&(xa(b,a,d,c),vd(b,a,d))}},rk="function"===typeof WeakMap?WeakMap:Map,tk=Sa.ReactCurrentOwner,ha=!1,Cf={dehydrated:null,treeContext:null,retryLane:0};var zk=function(a,b,c,d){for(c=b.child;null!==c;){if(5===c.tag||6===c.tag)a.appendChild(c.stateNode);else if(4!==c.tag&&null!==c.child){c.child.return=c;c=c.child;continue}if(c===b)break;for(;null===c.sibling;){if(null===c.return||c.return===b)return;c=c.return}c.sibling.return=
c.return;c=c.sibling}};var xi=function(a,b){};var yk=function(a,b,c,d,e){var f=a.memoizedProps;if(f!==d){a=b.stateNode;ub(Ea.current);e=null;switch(c){case "input":f=ke(a,f);d=ke(a,d);e=[];break;case "select":f=E({},f,{value:void 0});d=E({},d,{value:void 0});e=[];break;case "textarea":f=ne(a,f);d=ne(a,d);e=[];break;default:"function"!==typeof f.onClick&&"function"===typeof d.onClick&&(a.onclick=kd)}pe(c,d);var g;c=null;for(l in f)if(!d.hasOwnProperty(l)&&f.hasOwnProperty(l)&&null!=f[l])if("style"===
l){var h=f[l];for(g in h)h.hasOwnProperty(g)&&(c||(c={}),c[g]="")}else"dangerouslySetInnerHTML"!==l&&"children"!==l&&"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&"autoFocus"!==l&&($b.hasOwnProperty(l)?e||(e=[]):(e=e||[]).push(l,null));for(l in d){var k=d[l];h=null!=f?f[l]:void 0;if(d.hasOwnProperty(l)&&k!==h&&(null!=k||null!=h))if("style"===l)if(h){for(g in h)!h.hasOwnProperty(g)||k&&k.hasOwnProperty(g)||(c||(c={}),c[g]="");for(g in k)k.hasOwnProperty(g)&&h[g]!==k[g]&&(c||
(c={}),c[g]=k[g])}else c||(e||(e=[]),e.push(l,c)),c=k;else"dangerouslySetInnerHTML"===l?(k=k?k.__html:void 0,h=h?h.__html:void 0,null!=k&&h!==k&&(e=e||[]).push(l,k)):"children"===l?"string"!==typeof k&&"number"!==typeof k||(e=e||[]).push(l,""+k):"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&($b.hasOwnProperty(l)?(null!=k&&"onScroll"===l&&B("scroll",a),e||h===k||(e=[])):(e=e||[]).push(l,k))}c&&(e=e||[]).push("style",c);var l=e;if(b.updateQueue=l)b.flags|=4}};var Ak=function(a,
b,c,d){c!==d&&(b.flags|=4)};var Jd=!1,X=!1,Fk="function"===typeof WeakSet?WeakSet:Set,l=null,zi=!1,T=null,za=!1,Mk=Math.ceil,Od=Sa.ReactCurrentDispatcher,Uf=Sa.ReactCurrentOwner,ca=Sa.ReactCurrentBatchConfig,p=0,O=null,H=null,U=0,ba=0,Ga=bb(0),L=0,Jc=null,ra=0,Md=0,Sf=0,Kc=null,ja=null,Of=0,Hf=Infinity,Ra=null,Ed=!1,xf=null,ib=null,Pd=!1,lb=null,Qd=0,Ic=0,Pf=null,Kd=-1,Ld=0;var Qk=function(a,b,c){if(null!==a)if(a.memoizedProps!==b.pendingProps||S.current)ha=!0;else{if(0===(a.lanes&c)&&0===(b.flags&
128))return ha=!1,wk(a,b,c);ha=0!==(a.flags&131072)?!0:!1}else ha=!1,D&&0!==(b.flags&1048576)&&yh(b,nd,b.index);b.lanes=0;switch(b.tag){case 2:var d=b.type;Fd(a,b);a=b.pendingProps;var e=Nb(b,J.current);Sb(b,c);e=mf(null,b,d,a,e,c);var f=nf();b.flags|=1;"object"===typeof e&&null!==e&&"function"===typeof e.render&&void 0===e.$$typeof?(b.tag=1,b.memoizedState=null,b.updateQueue=null,ea(d)?(f=!0,ld(b)):f=!1,b.memoizedState=null!==e.state&&void 0!==e.state?e.state:null,ff(b),e.updater=Dd,b.stateNode=
e,e._reactInternals=b,uf(b,d,a,c),b=Af(null,b,d,!0,f,c)):(b.tag=0,D&&f&&Ue(b),aa(null,b,e,c),b=b.child);return b;case 16:d=b.elementType;a:{Fd(a,b);a=b.pendingProps;e=d._init;d=e(d._payload);b.type=d;e=b.tag=Uk(d);a=ya(d,a);switch(e){case 0:b=zf(null,b,d,a,c);break a;case 1:b=ri(null,b,d,a,c);break a;case 11:b=mi(null,b,d,a,c);break a;case 14:b=ni(null,b,d,ya(d.type,a),c);break a}throw Error(m(306,d,""));}return b;case 0:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),zf(a,b,d,e,c);
case 1:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),ri(a,b,d,e,c);case 3:a:{si(b);if(null===a)throw Error(m(387));d=b.pendingProps;f=b.memoizedState;e=f.element;Fh(a,b);wd(b,d,null,c);var g=b.memoizedState;d=g.element;if(f.isDehydrated)if(f={element:d,isDehydrated:!1,cache:g.cache,pendingSuspenseBoundaries:g.pendingSuspenseBoundaries,transitions:g.transitions},b.updateQueue.baseState=f,b.memoizedState=f,b.flags&256){e=Ub(Error(m(423)),b);b=ti(a,b,d,c,e);break a}else if(d!==e){e=
Ub(Error(m(424)),b);b=ti(a,b,d,c,e);break a}else for(fa=Ka(b.stateNode.containerInfo.firstChild),la=b,D=!0,wa=null,c=li(b,null,d,c),b.child=c;c;)c.flags=c.flags&-3|4096,c=c.sibling;else{Qb();if(d===e){b=Qa(a,b,c);break a}aa(a,b,d,c)}b=b.child}return b;case 5:return Ih(b),null===a&&Xe(b),d=b.type,e=b.pendingProps,f=null!==a?a.memoizedProps:null,g=e.children,Qe(d,e)?g=null:null!==f&&Qe(d,f)&&(b.flags|=32),qi(a,b),aa(a,b,g,c),b.child;case 6:return null===a&&Xe(b),null;case 13:return ui(a,b,c);case 4:return gf(b,
b.stateNode.containerInfo),d=b.pendingProps,null===a?b.child=Vb(b,null,d,c):aa(a,b,d,c),b.child;case 11:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),mi(a,b,d,e,c);case 7:return aa(a,b,b.pendingProps,c),b.child;case 8:return aa(a,b,b.pendingProps.children,c),b.child;case 12:return aa(a,b,b.pendingProps.children,c),b.child;case 10:a:{d=b.type._context;e=b.pendingProps;f=b.memoizedProps;g=e.value;y(ud,d._currentValue);d._currentValue=g;if(null!==f)if(ua(f.value,g)){if(f.children===
e.children&&!S.current){b=Qa(a,b,c);break a}}else for(f=b.child,null!==f&&(f.return=b);null!==f;){var h=f.dependencies;if(null!==h){g=f.child;for(var k=h.firstContext;null!==k;){if(k.context===d){if(1===f.tag){k=Pa(-1,c&-c);k.tag=2;var l=f.updateQueue;if(null!==l){l=l.shared;var p=l.pending;null===p?k.next=k:(k.next=p.next,p.next=k);l.pending=k}}f.lanes|=c;k=f.alternate;null!==k&&(k.lanes|=c);df(f.return,c,b);h.lanes|=c;break}k=k.next}}else if(10===f.tag)g=f.type===b.type?null:f.child;else if(18===
f.tag){g=f.return;if(null===g)throw Error(m(341));g.lanes|=c;h=g.alternate;null!==h&&(h.lanes|=c);df(g,c,b);g=f.sibling}else g=f.child;if(null!==g)g.return=f;else for(g=f;null!==g;){if(g===b){g=null;break}f=g.sibling;if(null!==f){f.return=g.return;g=f;break}g=g.return}f=g}aa(a,b,e.children,c);b=b.child}return b;case 9:return e=b.type,d=b.pendingProps.children,Sb(b,c),e=qa(e),d=d(e),b.flags|=1,aa(a,b,d,c),b.child;case 14:return d=b.type,e=ya(d,b.pendingProps),e=ya(d.type,e),ni(a,b,d,e,c);case 15:return oi(a,
b,b.type,b.pendingProps,c);case 17:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),Fd(a,b),b.tag=1,ea(d)?(a=!0,ld(b)):a=!1,Sb(b,c),ei(b,d,e),uf(b,d,e,c),Af(null,b,d,!0,a,c);case 19:return wi(a,b,c);case 22:return pi(a,b,c)}throw Error(m(156,b.tag));};var pa=function(a,b,c,d){return new Tk(a,b,c,d)},aj="function"===typeof reportError?reportError:function(a){console.error(a)};Ud.prototype.render=Xf.prototype.render=function(a){var b=this._internalRoot;if(null===b)throw Error(m(409));
Sd(a,b,null,null)};Ud.prototype.unmount=Xf.prototype.unmount=function(){var a=this._internalRoot;if(null!==a){this._internalRoot=null;var b=a.containerInfo;yb(function(){Sd(null,a,null,null)});b[Ja]=null}};Ud.prototype.unstable_scheduleHydration=function(a){if(a){var b=nl();a={blockedOn:null,target:a,priority:b};for(var c=0;c<Ya.length&&0!==b&&b<Ya[c].priority;c++);Ya.splice(c,0,a);0===c&&Hg(a)}};var Cj=function(a){switch(a.tag){case 3:var b=a.stateNode;if(b.current.memoizedState.isDehydrated){var c=
hc(b.pendingLanes);0!==c&&(xe(b,c|1),ia(b,P()),0===(p&6)&&(Hc(),db()))}break;case 13:yb(function(){var b=Oa(a,1);if(null!==b){var c=Z();xa(b,a,1,c)}}),Wf(a,1)}};var Gg=function(a){if(13===a.tag){var b=Oa(a,134217728);if(null!==b){var c=Z();xa(b,a,134217728,c)}Wf(a,134217728)}};var xj=function(a){if(13===a.tag){var b=hb(a),c=Oa(a,b);if(null!==c){var d=Z();xa(c,a,b,d)}Wf(a,b)}};var nl=function(){return z};var wj=function(a,b){var c=z;try{return z=a,b()}finally{z=c}};se=function(a,b,c){switch(b){case "input":le(a,
c);b=c.name;if("radio"===c.type&&null!=b){for(c=a;c.parentNode;)c=c.parentNode;c=c.querySelectorAll("input[name="+JSON.stringify(""+b)+'][type="radio"]');for(b=0;b<c.length;b++){var d=c[b];if(d!==a&&d.form===a.form){var e=Rc(d);if(!e)throw Error(m(90));jg(d);le(d,e)}}}break;case "textarea":og(a,c);break;case "select":b=c.value,null!=b&&Db(a,!!c.multiple,b,!1)}};(function(a,b,c){xg=a;yg=c})(Tf,function(a,b,c,d,e){var f=z,g=ca.transition;try{return ca.transition=null,z=1,a(b,c,d,e)}finally{z=f,ca.transition=
g,0===p&&Hc()}},yb);var ol={usingClientEntryPoint:!1,Events:[ec,Ib,Rc,ug,vg,Tf]};(function(a){a={bundleType:a.bundleType,version:a.version,rendererPackageName:a.rendererPackageName,rendererConfig:a.rendererConfig,overrideHookState:null,overrideHookStateDeletePath:null,overrideHookStateRenamePath:null,overrideProps:null,overridePropsDeletePath:null,overridePropsRenamePath:null,setErrorHandler:null,setSuspenseHandler:null,scheduleUpdate:null,currentDispatcherRef:Sa.ReactCurrentDispatcher,findHostInstanceByFiber:Xk,
findFiberByHostInstance:a.findFiberByHostInstance||Yk,findHostInstancesForRefresh:null,scheduleRefresh:null,scheduleRoot:null,setRefreshHandler:null,getCurrentFiber:null,reconcilerVersion:"18.3.1"};if("undefined"===typeof __REACT_DEVTOOLS_GLOBAL_HOOK__)a=!1;else{var b=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(b.isDisabled||!b.supportsFiber)a=!0;else{try{Uc=b.inject(a),Ca=b}catch(c){}a=b.checkDCE?!0:!1}}return a})({findFiberByHostInstance:ob,bundleType:0,version:"18.3.1-next-f1338f8080-20240426",
rendererPackageName:"react-dom"});Q.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=ol;Q.createPortal=function(a,b){var c=2<arguments.length&&void 0!==arguments[2]?arguments[2]:null;if(!Yf(b))throw Error(m(200));return Wk(a,b,null,c)};Q.createRoot=function(a,b){if(!Yf(a))throw Error(m(299));var c=!1,d="",e=aj;null!==b&&void 0!==b&&(!0===b.unstable_strictMode&&(c=!0),void 0!==b.identifierPrefix&&(d=b.identifierPrefix),void 0!==b.onRecoverableError&&(e=b.onRecoverableError));b=Vf(a,1,!1,null,null,
c,!1,d,e);a[Ja]=b.current;sc(8===a.nodeType?a.parentNode:a);return new Xf(b)};Q.findDOMNode=function(a){if(null==a)return null;if(1===a.nodeType)return a;var b=a._reactInternals;if(void 0===b){if("function"===typeof a.render)throw Error(m(188));a=Object.keys(a).join(",");throw Error(m(268,a));}a=Bg(b);a=null===a?null:a.stateNode;return a};Q.flushSync=function(a){return yb(a)};Q.hydrate=function(a,b,c){if(!Vd(b))throw Error(m(200));return Wd(null,a,b,!0,c)};Q.hydrateRoot=function(a,b,c){if(!Yf(a))throw Error(m(405));
var d=null!=c&&c.hydratedSources||null,e=!1,f="",g=aj;null!==c&&void 0!==c&&(!0===c.unstable_strictMode&&(e=!0),void 0!==c.identifierPrefix&&(f=c.identifierPrefix),void 0!==c.onRecoverableError&&(g=c.onRecoverableError));b=Wi(b,null,a,1,null!=c?c:null,e,!1,f,g);a[Ja]=b.current;sc(a);if(d)for(a=0;a<d.length;a++)c=d[a],e=c._getVersion,e=e(c._source),null==b.mutableSourceEagerHydrationData?b.mutableSourceEagerHydrationData=[c,e]:b.mutableSourceEagerHydrationData.push(c,e);return new Ud(b)};Q.render=
function(a,b,c){if(!Vd(b))throw Error(m(200));return Wd(null,a,b,!1,c)};Q.unmountComponentAtNode=function(a){if(!Vd(a))throw Error(m(40));return a._reactRootContainer?(yb(function(){Wd(null,null,a,!1,function(){a._reactRootContainer=null;a[Ja]=null})}),!0):!1};Q.unstable_batchedUpdates=Tf;Q.unstable_renderSubtreeIntoContainer=function(a,b,c,d){if(!Vd(c))throw Error(m(200));if(null==a||void 0===a._reactInternals)throw Error(m(38));return Wd(a,b,c,!1,d)};Q.version="18.3.1-next-f1338f8080-20240426"});
})();
+1078 -757
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -1,4 +1,18 @@
# overrides to s9pk.mk must precede the include statement # overrides to s9pk.mk must precede the include statement
ARCHES := x86 ARCHES := x86
# Gate the default `make` on the frontend render smoke check (catches the v78/v79
# blank-screen class that curl/health checks miss). render-smoke.mjs is dev/build-time
# only — it is not shipped in the image.
.DEFAULT_GOAL := verified-build
include s9pk.mk include s9pk.mk
render-smoke: | node_modules
@echo " Render smoke check (frontend)..."
@node render-smoke.mjs
verified-build: render-smoke
@$(MAKE) --no-print-directory $(ARCHES)
.PHONY: render-smoke verified-build
+838
View File
@@ -11,10 +11,140 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.19.0", "@types/node": "^22.19.0",
"@vercel/ncc": "^0.38.4", "@vercel/ncc": "^0.38.4",
"jsdom": "^25.0.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@iarna/toml": { "node_modules/@iarna/toml": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz",
@@ -94,6 +224,147 @@
"ncc": "dist/ncc/cli.js" "ncc": "dist/ncc/cli.js"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cssstyle/node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls/node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-equality-data-structures": { "node_modules/deep-equality-data-structures": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz",
@@ -103,6 +374,93 @@
"object-hash": "^3.0.0" "object-hash": "^3.0.0"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/fast-xml-builder": { "node_modules/fast-xml-builder": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
@@ -138,6 +496,181 @@
"fxparser": "src/cli/cli.js" "fxparser": "src/cli/cli.js"
} }
}, },
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ini": { "node_modules/ini": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz",
@@ -147,6 +680,13 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isomorphic-fetch": { "node_modules/isomorphic-fetch": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
@@ -157,6 +697,101 @@
"whatwg-fetch": "^3.4.1" "whatwg-fetch": "^3.4.1"
} }
}, },
"node_modules/jsdom": {
"version": "25.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz",
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
"decimal.js": "^10.4.3",
"form-data": "^4.0.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.5",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.12",
"parse5": "^7.1.2",
"rrweb-cssom": "^0.7.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.0.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^2.11.2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jsdom/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime": { "node_modules/mime": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
@@ -172,6 +807,36 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -192,6 +857,13 @@
} }
} }
}, },
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"dev": true,
"license": "MIT"
},
"node_modules/object-hash": { "node_modules/object-hash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@@ -201,6 +873,19 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-expression-matcher": { "node_modules/path-expression-matcher": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
@@ -232,6 +917,43 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rrweb-cssom": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/strnum": { "node_modules/strnum": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
@@ -244,6 +966,46 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -271,18 +1033,55 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-fetch": { "node_modules/whatwg-fetch": {
"version": "3.6.20", "version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -293,6 +1092,45 @@
"webidl-conversions": "^3.0.0" "webidl-conversions": "^3.0.0"
} }
}, },
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.3", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+1
View File
@@ -11,6 +11,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.19.0", "@types/node": "^22.19.0",
"@vercel/ncc": "^0.38.4", "@vercel/ncc": "^0.38.4",
"jsdom": "^25.0.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
// Frontend render smoke check (added v0.1.0:82). Dev/build-time only — NOT shipped in
// the image. Catches the v78/v79 blank-screen class that curl/health checks miss:
//
// Stage 1 (deterministic, no DOM): run the *shipped* Babel over the app's inline JSX
// and assert the output is a CLASSIC (non-module) script. Babel 8's preset-react
// defaults to the automatic JSX runtime, which emits `import {jsx} from
// "react/jsx-runtime"` — illegal in this inline <script> and blanks the whole app
// (the v79 incident). This stage fails loudly if a re-vendor reintroduces that.
// Stage 2 (jsdom mount): load index.html with the vendored React/ReactDOM/Babel served
// from disk, let Babel transform + execute the app exactly as a browser would, and
// assert React actually mounts the login UI into #root (the v78 blank-screen class).
//
// Run standalone: node render-smoke.mjs (from start9/0.4/; needs the jsdom devDep)
// Wired into the default `make` goal so every package build is gated on it.
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve, join } from 'node:path'
import vm from 'node:vm'
import { JSDOM, ResourceLoader, VirtualConsole } from 'jsdom'
const here = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(here, '..', '..') // start9/0.4 -> repo root
const frontendDir = join(repoRoot, 'frontend')
const vendorDir = join(frontendDir, 'assets', 'vendor')
const fail = (m) => { console.error(` FAIL ${m}`); process.exitCode = 1 }
const pass = (m) => console.log(` PASS ${m}`)
const html = readFileSync(join(frontendDir, 'index.html'), 'utf8')
const babelFile = (html.match(/src="\/assets\/vendor\/(babel-standalone-[^"]+)"/) || [])[1]
const inline = (html.match(/<script type="text\/babel">([\s\S]*?)<\/script>/) || [])[1]
if (!babelFile) fail('index.html references a vendored babel-standalone file')
if (!inline) fail('index.html has an inline <script type="text/babel"> block')
// ── Stage 1: transform with the shipped Babel; require a classic, parseable script ──
if (babelFile && inline) {
const sandbox = { console }
sandbox.window = sandbox
sandbox.self = sandbox
vm.runInNewContext(readFileSync(join(vendorDir, babelFile), 'utf8'), sandbox)
const Babel = sandbox.Babel || sandbox.window.Babel
if (!Babel) {
fail('vendored Babel did not expose a global Babel')
} else {
// No data-presets on the tag -> the app relies on the classic 'react' preset.
const out = Babel.transform(inline, { presets: ['react'] }).code
pass('inline JSX transforms under the shipped Babel')
if (/^\s*import[\s{('"*]/m.test(out) || /^\s*export[\s{ ]/m.test(out)) {
fail('transformed app has a top-level ESM import/export (the v79 blank-screen bug)')
} else {
pass('transformed app is a classic (non-module) script — no ESM import/export')
}
try { new vm.Script(out); pass('transformed app parses as an executable classic script') }
catch (e) { fail(`transformed app does not parse: ${e.message}`) }
}
}
// ── Stage 2: mount in jsdom and assert the login UI rendered ──
{
class DiskLoader extends ResourceLoader {
fetch(url) {
const m = url.match(/\/assets\/vendor\/([^?#]+)$/)
if (m) { try { return Promise.resolve(readFileSync(join(vendorDir, m[1]))) } catch { return null } }
return null // ignore fonts, favicon, API, etc.
}
}
const dom = new JSDOM(html, {
runScripts: 'dangerously',
resources: new DiskLoader(),
url: 'http://localhost:8080/',
pretendToBeVisual: true,
virtualConsole: new VirtualConsole(), // swallow app console noise; we assert on the DOM
})
const { window } = dom
// Stub the browser APIs jsdom lacks that an app may touch on first render, and
// neutralize the network so the initial data fetch can't crash the mount.
const noopMql = () => ({ matches: false, media: '', onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent() { return false } })
window.matchMedia = window.matchMedia || noopMql
window.scrollTo = window.scrollTo || (() => {})
class NoopObserver { observe() {} unobserve() {} disconnect() {} takeRecords() { return [] } }
window.ResizeObserver = window.ResizeObserver || NoopObserver
window.IntersectionObserver = window.IntersectionObserver || NoopObserver
window.fetch = () => new Promise(() => {}) // never resolves -> app stays on its initial render
const deadlineMs = 10000
const startedAt = Date.now()
await new Promise((done) => {
const tick = () => {
const root = window.document.getElementById('root')
if ((root && root.children.length > 0) || Date.now() - startedAt > deadlineMs) return done()
setTimeout(tick, 50)
}
if (window.document.readyState === 'complete') tick()
else window.addEventListener('load', tick)
})
const root = window.document.getElementById('root')
if (root && root.children.length > 0) pass('React mounted content into #root')
else fail('React did not mount any content into #root (blank screen)')
const loginEl = window.document.querySelector('.login-card, .login-title, .login-form')
const txt = window.document.body.textContent || ''
if (loginEl || /sign in|log in|password/i.test(txt)) pass('login UI rendered')
else fail('login UI did not render (no .login-* element or login text found)')
dom.window.close()
}
if (process.exitCode) console.error('\nRENDER SMOKE FAILED')
else console.log('\nALL PASS (render smoke)')
+19 -2
View File
@@ -42,8 +42,25 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * 0.1.0:74 (security/privacy hardening — full-eval P0+2×P1: close /assets/ path traversal, add NER backstop to the outreach redaction boundary, filter deleted_at on get-by-id) // * 0.1.0:74 (security/privacy hardening — full-eval P0+2×P1: close /assets/ path traversal, add NER backstop to the outreach redaction boundary, filter deleted_at on get-by-id)
// * 0.1.0:75 (Phase-A digest SMTP: per-package "Configure Digest SMTP" action writes /data/secrets/smtp/*; entrypoint exports SMTP_*; backend smtp_send.py + admin "send test email" endpoint + Settings→Admin "Send Test Digest Email" button) // * 0.1.0:75 (Phase-A digest SMTP: per-package "Configure Digest SMTP" action writes /data/secrets/smtp/*; entrypoint exports SMTP_*; backend smtp_send.py + admin "send test email" endpoint + Settings→Admin "Send Test Digest Email" button)
// * 0.1.0:76 (digest send via Gmail DWD: backend/email_integration/gmail_send.py uses the existing service account's gmail.compose scope for users.messages.send; digest_mailer prefers Gmail DWD and falls back to SMTP; the admin test endpoint + Settings button route through it — no app password needed when Gmail is enabled) // * 0.1.0:76 (digest send via Gmail DWD: backend/email_integration/gmail_send.py uses the existing service account's gmail.compose scope for users.messages.send; digest_mailer prefers Gmail DWD and falls back to SMTP; the admin test endpoint + Settings button route through it — no app password needed when Gmail is enabled)
// * Current: 0.1.0:77 (daily activity digest — Phase B: digest_builder builds by-team-member [per-user Spark narrative, never Claude] + by-investor [inbound+outbound, deduped] sections; always-on digest_scheduler reads a DB-backed policy; enable/send-time in Settings→Admin via GET/PATCH /api/admin/digest/policy; POST /api/admin/digest/send-now + "Send Digest Now" button) // * 0.1.0:77 (daily activity digest — Phase B: digest_builder builds by-team-member [per-user Spark narrative, never Claude] + by-investor [inbound+outbound, deduped] sections; always-on digest_scheduler reads a DB-backed policy; enable/send-time in Settings→Admin via GET/PATCH /api/admin/digest/policy; POST /api/admin/digest/send-now + "Send Digest Now" button)
export const PACKAGE_VERSION = '0.1.0:77' // * 0.1.0:78 (retire legacy lp_profiles + orphaned LP Tracker; Dashboard "Total Committed" repointed onto the fundraising grid [graveyard-excluded], "Total Funded" dropped; /api/lp-profiles* + lp-breakdown report removed; contact-dossier LP section + demo-seed LP block removed)
// * 0.1.0:79 (HOTFIX blank-screen: pin @babel/standalone@7.29.7 — the unpinned CDN upgraded to Babel 8, whose preset-react automatic JSX runtime emits an ESM import that blanks the classic inline-script app; plus close 3 server-side admin gaps: GET /api/users, /api/email/status, /api/email/accounts now require_admin)
// * 0.1.0:80 (repurpose Communications tab as the admin-only email-activity panel: new GET /api/email/activity [admin-enforced] over the email_* tables, filterable by investor/mailbox/direction + free-text search; classic manual log form retired; code-only, no schema change)
// * 0.1.0:81 (Communications tab is matched-only: query_email_activity gates on EXISTS email_investor_links, so unmatched cold/unknown-sender email is captured but never surfaced in the panel; code-only, no schema change)
// * 0.1.0:82 (vendor + SRI-pin the front-end libs: React/ReactDOM/Babel now ship in the s9pk and load same-origin from /assets/vendor/ with integrity hashes, so a CDN can never swap prod deps [the v78/v79 blank-screen class] and the box needs no outbound internet to render; plus a committed jsdom render smoke check [start9/0.4/render-smoke.mjs] gating the default `make` build)
// * 0.1.0:83 (email search/query + windowed digest preview, code-only: Communications investor dropdown now mirrors the list with typed keys [fund:/org:/contact:] so classic-contact/org-domain matches show + are pickable [fixes the empty-dropdown bug], plus a date-range filter, a click-to-expand full-body view [GET /api/email/detail], and a semantic "Search content" mode over indexed email bodies [GET /api/email/search -> ingest hybrid_search, soft-delete-filtered, 503 if Spark/Qdrant down]; Daily Digest gains an in-app windowed preview before send [POST /api/admin/digest/preview, send-now takes the same window] that exercises the real Spark summarizer without touching the daily cursor)
// * 0.1.0:84 (Matrix intake bot CRM support — ships the server side of commit 7ad0ee7, which was never packaged: new read-only GET /api/intake/match [new-vs-existing lookup against the canonical fundraising grid blob; returns the grid row id so an approved note lands on the matched investor, no duplicate] + source provenance on POST /api/fundraising/log-communication [audit records source, default "fundraising_grid"]; code-only, no schema change)
// * 0.1.0:85 (cosmetic: drop the redundant "[note]" tag from the fundraising-grid note line — now "YYYY-MM-DD Contact: summary"; informative comm types [call, meeting, …] keep their "[type]" tag; shared by the Matrix intake bot + grid-UI logging; no schema change)
// * 0.1.0:86 (Matrix intake fuzzy matching: GET /api/intake/match now returns ranked `candidates` [fuzzy near-matches — deterministic difflib name similarity + token overlap + email edit-distance ≤ 2, legal-suffix-aware] alongside the exact `match`, so the bot can surface near-duplicates ["Charlie"/"Charles", "Acme Capital"/"Acme Capital LLC", a one-char email typo] for human confirmation instead of silently creating a second investor; the bot-side disambiguation + conversational-edit UX ships on the Spark, not the s9pk; code-only, no schema change)
// * 0.1.0:87 (Adopt the Pipeline — grid drives the deal board: new "Add to Pipeline" row action creates+links an opportunity via opportunities.fundraising_investor_id [migration 0005, additive], reusing the grid's synced contact [no POST /api/contacts side-door] and mapping the grid lead→owner; idempotent [one live opp/investor, re-link never reseeds board-owned stage/probability]; read-only Pipeline + Pipeline Stage grid columns derived live from the linked opp; "Remove from Pipeline" soft-deletes the opp [grid row untouched]; deleting a grid investor archives its orphaned opp; folds in the soft-delete fix for the pipeline report + dashboard aggregates [archived opps no longer counted])
// * 0.1.0:88 (frontend-only: retire the Pipeline page's "+ New Opportunity" button + its create-by-contact modal — opportunities are now born only from a fundraising-grid investor row ["+ Pipeline"], so the board is a view + stage-management surface; replaced the button with a muted "Add deals from the Fundraising Grid" hint; removed the now-dead handler/state + the page's unused /api/contacts fetch)
// * 0.1.0:89 (email-proposal review over Matrix + a dedicated agent role: Email Capture's proposed grid notes gain a click-to-view inline popup of the source email [from/to/cc/date/subject/scrollable body, via the existing GET /api/email/detail]; and a CRM→Matrix review bridge — the intake bot [Spark] pulls pending proposals, posts a review card to a dedicated review room [MATRIX_EMAIL_REVIEW_ROOM], and relays in-thread yes/no/NL-edit back to the CRM, with web panel ↔ Matrix kept in sync [decide on either surface; the other reflects it]. New side table email_proposal_matrix [email-integration migration 0003, additive + idempotent] holds per-proposal Matrix thread state; new bot-or-admin endpoints GET /api/intake/email-proposals + .../{id}/matrix + .../{id}/decide, gated by a new 'bot' role [authenticated, never admin]. Bot poll loop + review-room handling ship on the Spark, not the s9pk)
// * 0.1.0:90 (give admins a UI path to provision the 'bot' role added in v89: the Settings → Admin edit-user role dropdown now offers "bot" alongside member/admin [the teammate-invite form stays member/admin only — provisioning an agent account is an admin re-classification, not an invite]; backend already accepted it; frontend-only, no schema change)
// * 0.1.0:91 (clarify email-proposal note wording: the proposed grid note now NAMES who emailed whom — "{teammate} emailed {investor}" outbound / "{sender} emailed the team" inbound — instead of a bare "Sent"/"Received"; also fixes a misclassification where a sender on our corporate domain whose mailbox isn't enrolled read as "Received" [outbound now also matches our domain, public providers like gmail excluded so an LP's gmail never reads as ours]; going-forward only, no schema change. Matrix-side review tweaks — dash separators + redacting decided cards — ship on the Spark, not the s9pk)
// * 0.1.0:92 (reminders & follow-ups, W1: new `reminders` table [in-app migration 0006; logical FK to fundraising_investors.id + denormalized name], full CRUD at /api/reminders [soft-delete; open/done/snoozed/cancelled; assignee; source human/bot/automation], read-only derived `reminder_status` grid column [overdue/due_soon/open — injected like pipeline_stage, filterable], orphan reconciler, Reminders page + Dashboard "Reminders Due" card + daily-digest "reminders due" section, and a per-investor last_activity_at recency rollup. Pure local CRM, no LLM path)
// * 0.1.0:93 (natural-language query, W2: read-only "ask the database in plain English" — a curated, parameterized query catalog [backend/nl_query/] behind a strict slot validator [the trust boundary — no generic SQL / dynamic identifiers]; a local-Qwen translator maps a question→{intent,slots} via Spark Control so the question never leaves the box [no Claude, no redaction]; new endpoints POST /api/query/nl + GET /api/query/catalog [require_bot_or_admin, audited entity_type='nl_query'], results never returned to any model; no schema change. The Matrix Q&A client [dedicated room + ?/@bot trigger] ships on the Spark, not the s9pk)
// * Current: 0.1.0:94 (NL-query correctness fix: the comms_by_user + email_counts_by_user intents were counting/listing a user's ENTIRE captured sent corpus [internal/vendor/personal], not only email to a matched investor — they lacked the EXISTS email_investor_links gate that recent_emails + the Communications panel use. Added the matched-only gate to both [+ a regression test seeding an unmatched sent email]; no schema change, no UI change)
export const PACKAGE_VERSION = '0.1.0:94'
export const DATA_MOUNT_PATH = '/data' export const DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080 export const WEB_PORT = 8080
+19 -2
View File
@@ -38,8 +38,25 @@ import { v_0_1_0_74 } from './v0.1.0.74'
import { v_0_1_0_75 } from './v0.1.0.75' import { v_0_1_0_75 } from './v0.1.0.75'
import { v_0_1_0_76 } from './v0.1.0.76' import { v_0_1_0_76 } from './v0.1.0.76'
import { v_0_1_0_77 } from './v0.1.0.77' import { v_0_1_0_77 } from './v0.1.0.77'
import { v_0_1_0_78 } from './v0.1.0.78'
import { v_0_1_0_79 } from './v0.1.0.79'
import { v_0_1_0_80 } from './v0.1.0.80'
import { v_0_1_0_81 } from './v0.1.0.81'
import { v_0_1_0_82 } from './v0.1.0.82'
import { v_0_1_0_83 } from './v0.1.0.83'
import { v_0_1_0_84 } from './v0.1.0.84'
import { v_0_1_0_85 } from './v0.1.0.85'
import { v_0_1_0_86 } from './v0.1.0.86'
import { v_0_1_0_87 } from './v0.1.0.87'
import { v_0_1_0_88 } from './v0.1.0.88'
import { v_0_1_0_89 } from './v0.1.0.89'
import { v_0_1_0_90 } from './v0.1.0.90'
import { v_0_1_0_91 } from './v0.1.0.91'
import { v_0_1_0_92 } from './v0.1.0.92'
import { v_0_1_0_93 } from './v0.1.0.93'
import { v_0_1_0_94 } from './v0.1.0.94'
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_0_1_0_77, current: v_0_1_0_94,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76], other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93],
}) })
+23
View File
@@ -0,0 +1,23 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Retire the legacy lp_profiles table + the orphaned LP Tracker page, and repoint the
// Dashboard "Total Committed" KPI onto the canonical fundraising grid. Code-only, no
// schema change (the empty lp_profiles table is left in place; migrations are no-ops):
// * Removed /api/lp-profiles* endpoints + handlers, the unused lp-breakdown report,
// the contact-dossier LP section, the demo-seed LP block, and (frontend) the
// orphaned LPTrackerPage component + its lp-tracker->fundraising-grid redirect.
// * Dashboard "Total Committed" now sums fundraising_investors.total_invested
// (graveyarded investors excluded) instead of the orphaned lp_profiles table, which
// read ~$0. "Total Funded" dropped (the grid has no funded-vs-committed concept).
// * Tests: test_dashboard_report.py added; test_soft_delete_reads.py updated.
export const v_0_1_0_78 = VersionInfo.of({
version: '0.1.0:78',
releaseNotes: {
en_US: [
'Dashboard "Total Committed" now reflects real committed capital from the fundraising grid',
'instead of the retired LP-profile table. Removed the unused LP Tracker page and its',
'legacy data endpoints; the grid remains the single source of truth for investors.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+25
View File
@@ -0,0 +1,25 @@
import { VersionInfo } from '@start9labs/start-sdk'
// HOTFIX — restore the web UI (every user was getting a blank screen) + close three
// server-side admin gaps. Code-only, no schema change (migrations are no-ops):
// * Pin @babel/standalone to 7.29.7. The page loaded Babel from unpkg with no version
// pin, so unpkg silently served Babel 8.0.0. Babel 8 defaults @babel/preset-react to
// the automatic JSX runtime, which prepends `import {jsx} from "react/jsx-runtime"`
// to the compiled output — an ESM import is illegal in this classic (non-module)
// inline <script>, so the browser rejected the whole bundle and React never mounted.
// The 7.x line defaults preset-react to the classic runtime (React.createElement),
// which restores the prior, working behavior. (Follow-up: vendor + SRI-pin the CDN
// libs so a third party can't swap our front-end deps in production again.)
// * Enforce admin server-side on three GET endpoints that were UI-hidden but not
// API-enforced: /api/users, /api/email/status, /api/email/accounts.
export const v_0_1_0_79 = VersionInfo.of({
version: '0.1.0:79',
releaseNotes: {
en_US: [
'Fixes a blank screen on load caused by an upstream Babel CDN upgrade; the web app',
'now loads reliably. Also tightens admin-only access controls on a few internal',
'endpoints. No data changes.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+21
View File
@@ -0,0 +1,21 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Repurpose the Communications tab as the admin-only email-activity panel. Code-only,
// no schema change (migrations are no-ops):
// * New GET /api/email/activity (admin-enforced server-side): captured-Gmail activity
// over the email_* tables, filterable by investor / mailbox / direction with
// free-text search. Soft-delete is honored on the per-mailbox sighting; direction is
// decided at the email level (outbound if the sender is one of our mailboxes).
// * Communications page rewritten to render that panel; the classic manual
// "Log Communication" form was retired (the grid context menu remains the log path).
// Nav item + page are admin-only.
export const v_0_1_0_80 = VersionInfo.of({
version: '0.1.0:80',
releaseNotes: {
en_US: [
'The Communications tab is now an admin-only email-activity view: search captured',
'Gmail by investor, mailbox, and direction. No data changes.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+18
View File
@@ -0,0 +1,18 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Communications tab is matched-only. Code-only, no schema change (migrations are no-ops):
// * query_email_activity now gates on EXISTS(email_investor_links), so the admin
// email-activity panel surfaces ONLY email that links to a known investor/contact.
// Unmatched cold/unknown-sender email is still captured (metadata-only) but never shown.
// * Graveyard investors are unaffected (their email has a link) — still hidden from the
// picker but visible/searchable as an audit surface.
export const v_0_1_0_81 = VersionInfo.of({
version: '0.1.0:81',
releaseNotes: {
en_US: [
'The Communications tab now shows only email matched to a known investor or contact.',
'Unmatched cold/unknown-sender email is captured but no longer surfaced. No data changes.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+21
View File
@@ -0,0 +1,21 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Vendor + SRI-pin the front-end libs. Code-only, no schema change (migrations are no-ops):
// * React 18.3.1, ReactDOM 18.3.1, and @babel/standalone 7.29.7 are now vendored into
// frontend/assets/vendor/ and loaded same-origin with sha384 integrity attributes,
// instead of from the unpkg CDN. A CDN can no longer swap our prod deps (the v78/v79
// blank-screen class), and the box needs no outbound internet to render the UI.
// * Adds start9/0.4/render-smoke.mjs — a jsdom render smoke check (transform-level +
// real mount of the login UI) wired into the default `make` goal, so every package
// build is gated on the frontend actually rendering. Dev/build-time only; not shipped.
export const v_0_1_0_82 = VersionInfo.of({
version: '0.1.0:82',
releaseNotes: {
en_US: [
'Front-end libraries (React, ReactDOM, Babel) are now bundled in the package and',
'integrity-checked instead of loaded from a CDN, so the app renders reliably with no',
'external dependency. No data changes.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+24
View File
@@ -0,0 +1,24 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Email search/query + windowed digest preview. Code-only, no schema change (migrations no-ops):
// * Communications tab: fixed the investor dropdown (was empty whenever email matched a
// classic contact / org domain rather than a grid investor) — the facet now mirrors the
// list with typed keys (fund:/org:/contact:); added a date-range filter and a click-to-
// expand full-body view (GET /api/email/detail), and a semantic "Search content" mode over
// indexed email bodies (GET /api/email/search -> ingest hybrid_search, soft-delete-filtered).
// * Daily digest: Settings -> Admin now builds a digest over a chosen window (last 24h or
// since a date) as an in-app preview before sending (POST /api/admin/digest/preview), and
// the manual send uses the same window. Exercises the real local-Spark summarizer; neither
// touches the daily schedule cursor.
export const v_0_1_0_83 = VersionInfo.of({
version: '0.1.0:83',
releaseNotes: {
en_US: [
'Communications: the investor dropdown now lists every matched relationship (not just',
'grid investors), plus a date-range filter, a full-body view, and a semantic content',
'search over email bodies. Daily Digest: preview a digest over any window before sending.',
'No data changes.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+22
View File
@@ -0,0 +1,22 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Matrix intake bot — CRM server-side support. Code-only, no schema change (migrations no-ops).
// These shipped in source with the intake bot (commit 7ad0ee7) but were never packaged; the bot
// (a separate process on the Spark) depends on them being live on the box:
// * GET /api/intake/match — read-only new-vs-existing lookup against the canonical fundraising
// grid blob; returns the grid row id so an approved note lands on the matched investor
// (no duplicate). The bot calls this to label its in-thread proposal new-vs-existing.
// * source provenance on POST /api/fundraising/log-communication — the audit entry now records
// a `source` (e.g. "matrix_intake"); defaults to "fundraising_grid", so existing callers
// (the grid UI) are unchanged.
export const v_0_1_0_84 = VersionInfo.of({
version: '0.1.0:84',
releaseNotes: {
en_US: [
'Matrix intake bot server support: a read-only investor-match endpoint so the bot can',
'recognize existing investors (no duplicates), plus write-provenance in the audit log.',
'No data changes.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+15
View File
@@ -0,0 +1,15 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Cosmetic: drop the redundant "[note]" tag from the fundraising-grid note line. The line is
// now "YYYY-MM-DD Contact: summary"; informative comm types (call, meeting, …) keep their
// "[type]" tag. Shared by the Matrix intake bot and any grid-UI logging. No schema change.
export const v_0_1_0_85 = VersionInfo.of({
version: '0.1.0:85',
releaseNotes: {
en_US: [
'Cleaner grid note lines: the redundant "[note]" tag is dropped (other communication',
'types keep their tag). No data changes.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+20
View File
@@ -0,0 +1,20 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Matrix intake — fuzzy investor matching. GET /api/intake/match now returns, alongside the
// exact `match`, a ranked list of `candidates`: fuzzy near-matches (deterministic difflib name
// similarity + token overlap + email edit-distance ≤ 2, legal-suffix-aware) the intake bot can
// surface in-thread for the human to pick from — so a near-duplicate name ("Charlie"/"Charles",
// "Acme Capital"/"Acme Capital LLC", a one-char email typo) no longer silently creates a second
// investor. Server-side only (the bot's disambiguation + conversational-edit UX ships on the
// Spark, not in the s9pk). Code-only, no schema change.
export const v_0_1_0_86 = VersionInfo.of({
version: '0.1.0:86',
releaseNotes: {
en_US: [
'Matrix intake: the new-vs-existing lookup now also returns ranked fuzzy near-matches,',
'so a typod or near-duplicate investor name is surfaced for confirmation instead of',
'silently creating a duplicate. No data changes.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+25
View File
@@ -0,0 +1,25 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Adopt the Pipeline — the fundraising grid (canonical) now drives the deal board.
// An "Add to Pipeline" row action creates and durably links an opportunity via the new
// opportunities.fundraising_investor_id column (backend migration 0005, additive +
// reversible — applied at app startup by core_migrations, so this StartOS migration is a
// no-op). The link reuses the grid's already-synced contact (no POST /api/contacts
// side-door) and maps the grid `lead` → owner; it is idempotent (one live opp per
// investor; a re-link never reseeds the board-owned stage/probability). Two read-only
// grid columns — Pipeline + Pipeline Stage — are derived live from the linked opp.
// "Remove from Pipeline" soft-deletes the opp (the grid row is untouched); deleting an
// investor from the grid archives its orphaned opp. Also folds in the soft-delete fix for
// the pipeline report + dashboard aggregates (archived opps are no longer counted).
export const v_0_1_0_87 = VersionInfo.of({
version: '0.1.0:87',
releaseNotes: {
en_US: [
'Adopt the Pipeline: flag an investor in the fundraising grid as a deal and it',
'creates a linked opportunity on the Pipeline board — no duplicate data entry, the',
'board owns the stage/odds, and a read-only Pipeline Stage column mirrors it back',
'into the grid. Removing from the pipeline archives the deal but leaves the grid intact.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+20
View File
@@ -0,0 +1,20 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Frontend-only follow-up to Adopt the Pipeline (v0.1.0:87). Retires the Pipeline page's
// "+ New Opportunity" button and its create-by-contact modal: an opportunity is now born
// ONLY from a fundraising-grid investor row ("+ Pipeline"), which matches how the team
// actually works (they live in the grid, not on the board). The board becomes a view +
// stage-management surface. The button is replaced with a muted "Add deals from the
// Fundraising Grid" hint; the dead handler/state and the page's unused /api/contacts fetch
// are removed. No schema change, no backend change.
export const v_0_1_0_88 = VersionInfo.of({
version: '0.1.0:88',
releaseNotes: {
en_US: [
'Pipeline board: removed the "+ New Opportunity" button — deals are now added only',
'from an investor row in the Fundraising Grid ("+ Pipeline"), keeping the grid the',
'single place you create work. No data changes.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+24
View File
@@ -0,0 +1,24 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Email-proposal review over Matrix + a dedicated agent role. The CRM-drafted "proposed grid
// notes" (Email Capture panel) gain (1) a click-to-view inline popup of the source email
// (from/to/cc/date/subject/scrollable body, via the existing /api/email/detail) so a reviewer
// can judge the note against the email, and (2) a CRM→Matrix review bridge: the intake bot
// (Spark) pulls pending proposals, posts a review card to a dedicated review room, and relays
// the human's in-thread yes/no/edit back to the CRM — with the web panel and Matrix kept in
// sync (decide on either; the other surface reflects it). New side table email_proposal_matrix
// (email-integration migration 0003, additive + idempotent — CREATE TABLE IF NOT EXISTS) holds
// the per-proposal Matrix thread state. New bot-or-admin endpoints under /api/intake/
// email-proposals (list/mark/decide), gated by a new 'bot' role (authenticated, never admin).
// The bot's poll loop + review-room handling ship on the Spark (git pull + restart), not here.
export const v_0_1_0_89 = VersionInfo.of({
version: '0.1.0:89',
releaseNotes: {
en_US: [
'Email Capture: click a proposed grid note to see the source email inline',
'(from/to/cc/date/subject + body) before approving, and review/approve/dismiss/edit',
'proposals from a dedicated Matrix room on mobile — decisions sync both ways.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+19
View File
@@ -0,0 +1,19 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Follow-up to v0.1.0:89: give admins a UI path to provision the 'bot' role. v89 added the role
// (for the Matrix email-review bot's endpoints) but deliberately kept it out of the UI, leaving
// no click-path to assign it. This adds 'bot' to the Settings → Admin edit-user role dropdown
// (the teammate-invite form stays member/admin only — provisioning an agent account is an admin
// re-classification of a dedicated user, not a teammate invite). Backend already accepts it.
// Frontend-only, no schema change.
export const v_0_1_0_90 = VersionInfo.of({
version: '0.1.0:90',
releaseNotes: {
en_US: [
'Admin user management: the role dropdown now offers "bot" (a dedicated agent service',
'account — authenticated but never admin), so the Matrix review bot\'s CRM user can be',
'provisioned in two clicks.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+21
View File
@@ -0,0 +1,21 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Clarify email-proposal note wording (part of a review-UX refinement; the matching Matrix-side
// tweaks — dash separators + redacting decided cards — ship on the Spark, not here). The proposed
// grid note now NAMES who emailed whom — "{teammate} emailed {investor}" (outbound) /
// "{sender} emailed the team" (inbound) — instead of a bare "Sent"/"Received" that left
// "who received?" ambiguous. Also fixes a misclassification where a sender on our corporate
// domain whose mailbox isn't enrolled read as "Received": outbound now also matches our domain
// (public providers like gmail excluded, so an LP's gmail never reads as ours). Going-forward
// only (existing proposals keep their text); no schema change.
export const v_0_1_0_91 = VersionInfo.of({
version: '0.1.0:91',
releaseNotes: {
en_US: [
'Proposed grid notes now name who emailed whom (e.g. "Jane emailed Acme Capital")',
'instead of an ambiguous "Sent"/"Received", and correctly classify mail from your',
'own domain as outbound even when that mailbox isn\'t enrolled.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+22
View File
@@ -0,0 +1,22 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Reminders & follow-ups (W1). First-class tickler tied to the fundraising grid: a new
// `reminders` table (in-app migration 0006; logical FK to fundraising_investors.id +
// denormalized name, like the pipeline link), full CRUD at /api/reminders (soft-delete;
// status open/done/snoozed/cancelled; assignee; source human/bot/automation), a read-only
// derived `reminder_status` grid column (overdue/due_soon/open — injected like pipeline_stage,
// filterable so a saved view can drive the follow-up view off real reminders), an orphan
// reconciler (cancels reminders when their investor leaves the grid), a Reminders page, a
// Dashboard "Reminders Due" card, and a "Reminders due" daily-digest section. Pure local CRM
// data — no LLM path. up/down are no-ops; the real SQLite migration runs in-app at startup.
export const v_0_1_0_92 = VersionInfo.of({
version: '0.1.0:92',
releaseNotes: {
en_US: [
'Reminders & follow-ups: set a due-dated reminder on any investor from the grid,',
'track them on the new Reminders page + Dashboard card, and get an overdue/due-today',
'section in the daily digest.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+22
View File
@@ -0,0 +1,22 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Natural-language query (W2) — read-only "ask the database in plain English". A curated,
// parameterized query catalog (backend/nl_query/) sits behind a strict slot validator that is
// the trust boundary: a caller (or the local model) supplies only typed slot VALUES, never a
// table/column, an operator, or SQL. A local-Qwen translator maps a question -> {intent, slots}
// via Spark Control, so the question never leaves the box (no Claude, no redaction); results
// never go back to any model. New endpoints POST /api/query/nl + GET /api/query/catalog
// (require_bot_or_admin, audited as entity_type='nl_query'). No schema change — read-only — so
// up/down are no-ops. The Matrix Q&A client (a dedicated room + the ?/@bot trigger) ships
// separately on the Spark, not in this s9pk.
export const v_0_1_0_93 = VersionInfo.of({
version: '0.1.0:93',
releaseNotes: {
en_US: [
'Natural-language query (read-only): ask the fundraising database in plain English from',
'the Matrix Q&A room — translated on-box by the local model (nothing leaves the box),',
'answering only curated, parameterized queries.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})
+20
View File
@@ -0,0 +1,20 @@
import { VersionInfo } from '@start9labs/start-sdk'
// NL-query correctness fix (W2). The comms_by_user and email_counts_by_user intents counted /
// listed a user's ENTIRE captured sent corpus (internal / vendor / personal mail), not only
// email to a matched investor — they were missing the `EXISTS email_investor_links` gate that
// recent_emails and the Communications panel's query_email_activity already use. Added the
// matched-only gate to both (mirroring query_email_activity), so "emails sent by <user>" and
// "how many emails did <user> send" answer about investor relationships only. No schema change,
// no UI change — up/down are no-ops.
export const v_0_1_0_94 = VersionInfo.of({
version: '0.1.0:94',
releaseNotes: {
en_US: [
'Natural-language query fix: "emails sent by <user>" and per-user email counts now count',
'only email to matched investors (not internal/vendor/personal mail), matching the',
'Communications panel.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})