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
This commit is contained in:
@@ -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
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
Executable
+122
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user