Phase 1: investor↔contacts (member_of), system status, thesis seed v1
- entity_resolution: emit member_of relationship edges (contact -> investor), so one investor entity owns many contacts (institution) and a HNWI is the N=1 case; crm_tools.get_investor_contacts + get_entity contacts/member_of; MCP tool. - seed_synthetic: multi-contact institutions to exercise it (Harbor & Vine = 5). - server.py: GET /api/system/status (index/entity/thesis/activity health) for an in-app status view (no shell needed to verify the index). - docs/thesis-seed-v1.md: grounded v1 thesis (throughline, 6 pillars, objections, per-segment angles, voice) drawn from Ten31's newsletter/site/essays. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -189,6 +189,17 @@ def resolve_people(conn, org_canon_by_orgid, org_canon_by_fundinv, merge_map=Non
|
||||
display = full.strip() or email
|
||||
_upsert_entity(conn, cid, "person", display, email)
|
||||
_link(conn, cid, model, sid, match_value, match_kind, conf)
|
||||
# Record that this contact (person) belongs to its investor/org entity, so
|
||||
# one investor can own many contacts (e.g. a family office with several
|
||||
# people) — and a 1-contact HNWI is just the N=1 case.
|
||||
if org_canon and cid != org_canon:
|
||||
conn.execute("""
|
||||
INSERT INTO relationship_edges (id, src_id, dst_id, edge_type, source, strength, directed,
|
||||
first_seen_at, last_seen_at, created_at, updated_at)
|
||||
VALUES (?,?,?, 'member_of', 'entity_resolution', 1.0, 1, ?, ?, ?, ?)
|
||||
ON CONFLICT(src_id, dst_id, edge_type, source)
|
||||
DO UPDATE SET last_seen_at=excluded.last_seen_at, updated_at=excluded.updated_at
|
||||
""", (str(uuid.uuid4()), cid, org_canon, _now(), _now(), _now(), _now()))
|
||||
if model == "contacts":
|
||||
contact_to_person[sid] = cid
|
||||
meta = person_meta.setdefault(cid, {"org": org_canon, "last": _split_name(full)[1],
|
||||
|
||||
@@ -63,10 +63,34 @@ def get_entity(lp_id, db=None):
|
||||
out["interaction_count"] = (c.execute(
|
||||
"SELECT COUNT(*) FROM communications WHERE contact_id IN (%s)" % ",".join("?" * len(cids)),
|
||||
list(cids)).fetchone()[0] if cids else 0)
|
||||
# An investor's contacts (member_of edges) — and, for a person, the investor(s)
|
||||
# they belong to. This is how one investor owns many contacts.
|
||||
out["contacts"] = [dict(r) for r in c.execute(
|
||||
"SELECT ce.id, ce.display_name, ce.primary_email FROM relationship_edges re "
|
||||
"JOIN canonical_entities ce ON ce.id=re.src_id "
|
||||
"WHERE re.dst_id=? AND re.edge_type='member_of' AND ce.deleted_at IS NULL ORDER BY ce.display_name", (lp_id,))]
|
||||
out["member_of"] = [dict(r) for r in c.execute(
|
||||
"SELECT ce.id, ce.display_name, ce.entity_kind FROM relationship_edges re "
|
||||
"JOIN canonical_entities ce ON ce.id=re.dst_id "
|
||||
"WHERE re.src_id=? AND re.edge_type='member_of' AND ce.deleted_at IS NULL", (lp_id,))]
|
||||
out["contact_count"] = len(out["contacts"])
|
||||
c.close()
|
||||
return out
|
||||
|
||||
|
||||
def get_investor_contacts(lp_id, db=None):
|
||||
"""List all contacts (person entities) that belong to an investor entity —
|
||||
the explicit one-investor-to-many-contacts relationship."""
|
||||
c = _conn(db)
|
||||
inv = c.execute("SELECT id, entity_kind, display_name FROM canonical_entities WHERE id=?", (lp_id,)).fetchone()
|
||||
contacts = [dict(r) for r in c.execute(
|
||||
"SELECT ce.id, ce.display_name, ce.primary_email FROM relationship_edges re "
|
||||
"JOIN canonical_entities ce ON ce.id=re.src_id "
|
||||
"WHERE re.dst_id=? AND re.edge_type='member_of' AND ce.deleted_at IS NULL ORDER BY ce.display_name", (lp_id,))]
|
||||
c.close()
|
||||
return {"investor": dict(inv) if inv else None, "contacts": contacts, "contact_count": len(contacts)}
|
||||
|
||||
|
||||
def search_records(query=None, entity_kind=None, limit=20, db=None):
|
||||
"""Structured search over canonical entities (name substring + kind)."""
|
||||
c = _conn(db)
|
||||
|
||||
@@ -47,6 +47,13 @@ def get_interaction_history(lp_id: str, limit: int = 20) -> dict:
|
||||
return t.get_interaction_history(lp_id, limit=limit)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_investor_contacts(lp_id: str) -> dict:
|
||||
"""List all contacts (people) belonging to an investor entity — the
|
||||
one-investor-to-many-contacts relationship (e.g. a family office's several people)."""
|
||||
return t.get_investor_contacts(lp_id)
|
||||
|
||||
|
||||
# ── retrieval modes ──
|
||||
@mcp.tool()
|
||||
def hybrid_search(query: str, top_k: int = 8, lp_id: str = "", doc_type: str = "",
|
||||
|
||||
@@ -146,6 +146,21 @@ def main():
|
||||
match_email = email if i % 2 == 0 else "" # half share email (easy), half don't (hard)
|
||||
overlap_specs.append((org_name, f"{variant} {last}", match_email))
|
||||
|
||||
# Multi-contact institutions: the first two orgs get extra contacts so ONE
|
||||
# investor entity owns several people (a family office / institution), to
|
||||
# exercise the member_of relationship. (A HNWI stays a 1-contact investor.)
|
||||
for org_name in (ORGS[0][0], ORGS[1][0]):
|
||||
for k in range(2):
|
||||
fn, ln = FIRST[(k + 13) % len(FIRST)], LAST[(k + 13) % len(LAST)]
|
||||
cid = gen()
|
||||
conn.execute(
|
||||
"INSERT INTO contacts (id, first_name, last_name, email, title, organization_id, contact_type, "
|
||||
"status, source, notes, created_by, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(cid, fn, ln, f"{fn.lower()}.{ln.lower()}@{org_name.split()[0].lower()}.invalid",
|
||||
random.choice(["Analyst", "Principal", "Associate"]), org_ids[org_name], "investor", "active",
|
||||
"referral", f"Additional contact at {org_name}.", uid, now()))
|
||||
contacts.append((cid, fn, ln, org_name, "investor"))
|
||||
|
||||
# extra prospect contacts (no org sometimes)
|
||||
for j in range(12):
|
||||
first = FIRST[(j + 8) % len(FIRST)]
|
||||
|
||||
@@ -1728,6 +1728,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_get_fundraising_activity(user, params)
|
||||
if path == '/api/security/status':
|
||||
return self.handle_security_status(user)
|
||||
if path == '/api/system/status':
|
||||
return self.handle_system_status(user)
|
||||
|
||||
# Users
|
||||
if path == '/api/users':
|
||||
@@ -3428,6 +3430,41 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
return self.send_json({"message": "Tag deleted"})
|
||||
|
||||
def handle_system_status(self, user):
|
||||
"""System / search-index health for the in-app status view (DB-derived)."""
|
||||
conn = get_db()
|
||||
out = {}
|
||||
try:
|
||||
live = "deleted_at IS NULL"
|
||||
out['canonical_entities'] = {
|
||||
'lp': conn.execute(f"SELECT COUNT(*) FROM canonical_entities WHERE entity_kind='lp' AND {live}").fetchone()[0],
|
||||
'organization': conn.execute(f"SELECT COUNT(*) FROM canonical_entities WHERE entity_kind='organization' AND {live}").fetchone()[0],
|
||||
'person': conn.execute(f"SELECT COUNT(*) FROM canonical_entities WHERE entity_kind='person' AND {live}").fetchone()[0],
|
||||
}
|
||||
out['entity_links'] = conn.execute("SELECT COUNT(*) FROM entity_links").fetchone()[0]
|
||||
except Exception:
|
||||
out['canonical_entities'] = None
|
||||
try:
|
||||
r = conn.execute("SELECT ts, payload FROM interaction_log WHERE action='ingest.sync' ORDER BY ts DESC LIMIT 1").fetchone()
|
||||
out['last_index_sync'] = ({'ts': r['ts'], **json.loads(r['payload'] or '{}')} if r else None)
|
||||
except Exception:
|
||||
out['last_index_sync'] = None
|
||||
try:
|
||||
out['thesis'] = {
|
||||
'lines': conn.execute("SELECT COUNT(*) FROM thesis_lines WHERE deleted_at IS NULL").fetchone()[0],
|
||||
'canonical_versions': conn.execute("SELECT COUNT(*) FROM thesis_versions WHERE status='canonical'").fetchone()[0],
|
||||
'in_review': conn.execute("SELECT COUNT(*) FROM thesis_versions WHERE status='in_review'").fetchone()[0],
|
||||
}
|
||||
except Exception:
|
||||
out['thesis'] = None
|
||||
try:
|
||||
out['recent_activity'] = [dict(r) for r in conn.execute(
|
||||
"SELECT ts, actor_type, actor_id, action FROM interaction_log ORDER BY ts DESC LIMIT 12")]
|
||||
except Exception:
|
||||
out['recent_activity'] = []
|
||||
conn.close()
|
||||
self.send_json({"data": out})
|
||||
|
||||
# ─── Architect thesis (Phase 1) ───
|
||||
def handle_list_thesis_lines(self, user):
|
||||
if thesis_review is None:
|
||||
|
||||
Reference in New Issue
Block a user