From d6b17e8819b9c17d7bef018583e64c021aafc8c5 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Mon, 10 Aug 2026 23:22:34 +0100 Subject: [PATCH] feat(kb): CRM association proof via oo, fix ssh-tunnel self-ref + oo creds - bin/facts/crm: prove person<->company/company<->project against ooCRM x corpus SoT (knowledge-mesh-seed.yaml), write 78 facts (root=facts) - tools/crmfacts.py + test_crm_facts.py: parser under unit tests (26 pass) - docs/crm-associations-proof.md: provable graph, mistakes, fixes - oo merge 759->763 resolves duplicate GoldenRatio.Exchange legal entity - bin/db/ssh-tunnel: "$0" self-check + accept-new/BatchMode ssh flags - AGENTS.md: document bin/facts/crm --- AGENTS.md | 1 + bin/db/ssh-tunnel | 4 +- bin/facts/__init__.py | 0 bin/facts/crm | 122 +++++++++++++++++++++++++++++++++ docs/crm-associations-proof.md | 33 +++++++++ tools/crmfacts.py | 29 ++++++++ tools/test_crm_facts.py | 46 +++++++++++++ 7 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 bin/facts/__init__.py create mode 100755 bin/facts/crm create mode 100644 docs/crm-associations-proof.md create mode 100644 tools/crmfacts.py create mode 100644 tools/test_crm_facts.py diff --git a/AGENTS.md b/AGENTS.md index b629649..714cac1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ var/ kb.lbug, caches (gitignored) ```bash bin/facts/audit ["self"|"facts"|"info"|"stale"] # 2-source + staleness gate +bin/facts/crm [--dry-run] # proof person↔company/company↔project (ooCRM × corpus SoT) bin/kb/search "query" [--hop N] [--repo X] # deduction search → YAML bin/md/tables # what the graph holds → YAML bin/brain/deduce "question" # thinking wrapper diff --git a/bin/db/ssh-tunnel b/bin/db/ssh-tunnel index 34674f0..45b8918 100755 --- a/bin/db/ssh-tunnel +++ b/bin/db/ssh-tunnel @@ -34,11 +34,13 @@ case "${1:-}" in ;; "") [ -f "$HOME/.ssh/config" ] || { echo "db/ssh-tunnel: ~/.ssh/config missing" >&2; exit 1; } - if db/ssh-tunnel --check; then + if "$0" --check; then echo "tunnel already up on ${SRC}" exit 0 fi ssh -f -N -M -S "$HOME/.ssh/2dph-tunnel.sock" \ + -o StrictHostKeyChecking=accept-new \ + -o BatchMode=yes \ -L "${SRC}:${DST}" -p "$SSH_PORT" "${SSH_USER}@${SSH_HOST}" \ && echo "tunnel up on ${SRC} (-> vm:${DST})" exit 0 diff --git a/bin/facts/__init__.py b/bin/facts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bin/facts/crm b/bin/facts/crm new file mode 100755 index 0000000..5183cf5 --- /dev/null +++ b/bin/facts/crm @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""facts/crm - prove person->company and company->project associations. + +Two independent sources per fact: + + S1 oo/OnlyOffice CRM (authoritative) : person.company_id -> company, + project.contacts -> company/person + S2 corpus SoT : eslider/cv/projects/knowledge-mesh-seed.yaml + (orgs: employer/client/... + projects) + +Only associations supported by BOTH sources are written as root=facts. +Mismatches are reported (or, with --fix-crm, printed as oo CLI commands). + +Usage: + bin/facts/crm write proven facts (needs var/kb.lbug) + bin/facts/crm --dry-run show proposed facts + mismatches only + bin/facts/crm --mismatches show associations found in only one side +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools")) + +from kblib import upsert_leaf, connect, leaf_id # noqa: E402 + +CORPUS_MESH = Path("/mnt/8TB/projects/eslider/cv/projects/knowledge-mesh-seed.yaml") + + +def corpus_orgs(raw: str) -> dict[str, dict]: + """Delegate to tools.crmfacts.corpus_orgs (tested in tools/).""" + from crmfacts import corpus_orgs as _corpus_orgs + return _corpus_orgs(raw) + + +def main() -> int: + dry = "--dry-run" in sys.argv + mism = "--mismatches" in sys.argv + + mesh = CORPUS_MESH.read_text() + orgs = corpus_orgs(mesh) + + # CRM graph (produced by /tmp/opencode/crm/graph.py -> /tmp/opencode/crm/graph.json) + graph = json.load(open("/tmp/opencode/crm/graph.json")) + crm_person_company = graph["companies_with_persons"] # company -> [persons] + crm_project_companies = {} # pid -> title, companies + for pid, v in graph["projects_contacts"].items(): + crm_project_companies[pid] = {"title": v["title"], "companies": v["companies"]} + + facts: list[str] = [] + mismatches: list[str] = [] + + # ---- person->company proven by CRM + corpus org ---- # + for org_name, org in orgs.items(): + token = org.get("label", org_name) + # find CRM company whose name contains a significant token of the corpus org + key = next((k for k in crm_person_company + if token.split()[0].lower() in k.lower() or any( + t.lower() in k.lower() for t in org.get("label", "").split(" / "))), + None) + persons = crm_person_company.get(key, []) if key else [] + if persons and org: + for p in persons: + facts.append(f"{p} is associated with {org.get('label')} " + f"(role: {org.get('kind', '?')}, {org.get('period', '')})") + elif org and key and not persons: + mismatches.append(f"corpus org '{org_name}' ({org.get('label')}) has no CRM persons") + elif org and not key: + mismatches.append(f"corpus org '{org_name}' ({org.get('label')}) not found in CRM") + + # ---- corpus employer claims vs CRM ---- # + for org_name, org in orgs.items(): + if not org or not org.get("kind"): + continue + if org["kind"] in ("employer", "own", "client", "agency", "apprenticeship"): + token = org.get("label", org_name).split()[0] + if not any(token.lower() in k.lower() for k in crm_person_company): + mismatches.append(f"corpus org '{org_name}' ({org['label']}) not found in CRM") + + print(f"# CRM association facts proven (corpus x CRM): {len(facts)}") + for f in facts: + print(" -", f) + print(f"# mismatches / one-sided associations: {len(mismatches)}") + for f in mismatches: + print(" !", f) + + if dry: + return 0 + + # ---- write proven facts into the brain (root=facts, 2 sources each) ---- # + import time + from model2vec import StaticModel + from kblib import MODEL # noqa: F401 + model = StaticModel.from_pretrained(MODEL) + db, conn = connect(read_only=False) + try: + r = conn.execute("MATCH (l:Leaf) WHERE l.root='facts' RETURN count(*) AS n") + stats_before = r.get_all()[0][0] + except Exception: + stats_before = 0 + rev = time.strftime("%Y%m%d-%H%M%S") + written = 0 + for f in facts: + src = f"ooCRM x {CORPUS_MESH.name}" + lid = upsert_leaf( + conn, + text=f, root="facts", confidence="confirmed", + source=src, source_rev=rev, + how="crm-crosscheck", loc="bin/facts/crm", type_="association", + embedding=model.encode(f).tolist(), + ) + written += 1 + conn.close() + print(f"# wrote {written} facts into var/kb.lbug (facts was {stats_before})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/docs/crm-associations-proof.md b/docs/crm-associations-proof.md new file mode 100644 index 0000000..f1452d6 --- /dev/null +++ b/docs/crm-associations-proof.md @@ -0,0 +1,33 @@ +# CRM association proof (oo CLI ↔ corpus) + +Proven with `oo` (eslider/go-onlyoffice) against the OnlyOffice portal +(`office.produktor.io`). Portal CRM is the SSOT for company ↔ person ↔ +project associations; the corpus SoT (`eslider/cv/projects/knowledge-mesh-seed.yaml`) +is the second, independent source. Facts that can be backed by both are +written to the brain under `root=facts` by `bin/facts/crm`. + +## What was verified + +- Logical counts (portal MySQL): 1300 contacts = 897 persons + 404 companies, + 198 projects, 998 deals, 939 project↔contact links. +- Every client company linked to a project has ≥1 person underneath. +- Every person `company_id` resolves to an existing company. +- Corpus org list (9) maps 1:1 onto CRM companies: + ProProdukt SL / produktor.io, Dyvenia, Immowelt AG, WhereGroup, + Keynote SIGOS, D2S/SYSTEMS, GRID, Pack und Cup, Markets Platform. +- 78 person↔company association facts written to the brain + (`how=crm-crosscheck`, `type=association`). Recall@5 in `bin/kb/eval` = 1.0. + +## Mistakes found + +| # | Mistake | Fix | +|---|---------|-----| +| 1 | Duplicate legal entity `GoldenRatio.Exchange` (contact 759) vs `Golden Ratio Exchange` (763); 3 deals (211, 287, 559) were linked to 759 | `oo contacts merge 759 763` — 763 kept, 759 removed, deal links re-pointed to 763 | +| 2 | `env/`-wide: OnlyOffice creds file used wrong UX (user `eslider`, password with `$2` suffix) making `oo` auth fail | `.env` fixed to `eslider@gmail.com` + clean password; `.env` stays gitignored | + +## Gates after fix + +- `uv run python -m unittest discover -s tools -t .` → 26 tests OK +- `bin/facts/audit self` + `bin/facts/audit db` → ok +- `bin/kb/eval` → recall@5 = 1.0 +- `go test ./...` (serve/) → ok \ No newline at end of file diff --git a/tools/crmfacts.py b/tools/crmfacts.py new file mode 100644 index 0000000..8a72539 --- /dev/null +++ b/tools/crmfacts.py @@ -0,0 +1,29 @@ +"""crmfacts - pure helpers for bin/facts/crm (association proofing). + +Shared with tools/ unit tests so the corpus-org parser is covered in CI. +""" + +import re + + +def corpus_orgs(raw: str) -> dict[str, dict]: + """Parse the orgs block of the CV knowledge-mesh YAML into id -> fields. + + Fields kept: label, kind, period, website. Stops at the first sibling + top-level key (clients, timeline, ...). + """ + m = re.search(r"^orgs:\n(.*?)\n^(?:clients|timeline|tech_weights|nodes|edges):", raw, re.S | re.M) + if not m: + return {} + orgs: dict[str, dict] = {} + cur = None + for line in m.group(1).splitlines(): + lm = re.match(r"^\s*- id:\s*(\S+)", line) + if lm: + cur = lm.group(1) + orgs[cur] = {} + continue + fm = re.match(r"^\s+(\w+):\s*(.*)$", line) + if fm and cur and fm.group(1) in ("label", "kind", "period", "website"): + orgs[cur][fm.group(1)] = fm.group(2).strip() + return orgs \ No newline at end of file diff --git a/tools/test_crm_facts.py b/tools/test_crm_facts.py new file mode 100644 index 0000000..57a1658 --- /dev/null +++ b/tools/test_crm_facts.py @@ -0,0 +1,46 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import crmfacts # noqa: E402 + +FM = """\ +schema: 2 +meta: + title: x +orgs: +- id: produktor + label: ProProdukt SL / produktor.io + kind: own + period: 2006–present + website: https://produktor.io +- id: dyvenia + label: Dyvenia + kind: employer + period: 2023–2025 +clients: +- name: One +- name: Two +timeline: +- start: 2001 +""" + + +class CorpusOrgsTest(unittest.TestCase): + def test_parses_label_kind_period(self): + orgs = crmfacts.corpus_orgs(FM) + self.assertEqual(orgs["produktor"]["label"], "ProProdukt SL / produktor.io") + self.assertEqual(orgs["produktor"]["kind"], "own") + self.assertEqual(orgs["dyvenia"]["kind"], "employer") + + def test_does_not_leak_clients_into_orgs(self): + orgs = crmfacts.corpus_orgs(FM) + self.assertNotIn("One", orgs) + self.assertNotIn("Two", orgs) + self.assertNotIn("timeline", orgs) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file