[object Object]

← back to A2a Lab

add SDK-native client (client_sdk.py) + lexical-semantic find (semantic.py), verified

714e4928d47bc4a77d585af488fa922e6cae0cae · 2026-08-01 22:05:30 -0700 · Steve

client_sdk.py: A2ACardResolver + ClientFactory.create + async send_message (StreamResponse).
semantic.py: TF-IDF + synonym map + char-trigram cosine; wired into directory find w/ keyword fallback.
Ollama has no --embeddings (gated fleet change) so neural embeddings deferred; documented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 714e4928d47bc4a77d585af488fa922e6cae0cae
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Aug 1 22:05:30 2026 -0700

    add SDK-native client (client_sdk.py) + lexical-semantic find (semantic.py), verified
    
    client_sdk.py: A2ACardResolver + ClientFactory.create + async send_message (StreamResponse).
    semantic.py: TF-IDF + synonym map + char-trigram cosine; wired into directory find w/ keyword fallback.
    Ollama has no --embeddings (gated fleet change) so neural embeddings deferred; documented.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 README.md            |  18 ++++++++++
 cabinet_directory.py |  34 +++++++++++++-----
 client_sdk.py        |  69 +++++++++++++++++++++++++++++++++++
 semantic.py          | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 213 insertions(+), 8 deletions(-)

diff --git a/README.md b/README.md
index 0ed7819..86a64c0 100644
--- a/README.md
+++ b/README.md
@@ -136,6 +136,24 @@ Skills: `list` (all officers + skill counts), `find <task>` (rank officers by tr
 match — e.g. "rotate a leaked api key" → **vp-security**), `card <vp>` (full card). Verified
 end-to-end over JSON-RPC. This is the discovery/routing layer the cards were built for.
 
+## SDK-native client + semantic routing (verified)
+
+**`client_sdk.py`** — the official a2a-sdk client stack (vs `client.py`'s raw JSON-RPC):
+`A2ACardResolver.get_agent_card()` → `ClientFactory(ClientConfig(httpx_client=…)).create(card)`
+→ async `client.send_message(SendMessageRequest(...))`, reading text from the `StreamResponse`
+oneof. The SDK owns the proto request + `SendMessage` method; we supply `A2A-Version:1.0`
+via the httpx client. Verified against the echo agent and the directory.
+
+**Semantic `find`** (`semantic.py`) — replaces raw keyword-hit-count with a lexical-semantic
+score: TF-IDF term weighting + a domain synonym map + char-trigram cosine. So
+"harden the firewall against intruders" → **vp-security 0.474** (matched backdoor/breach/cve/
+firewall/harden/intrusion, not just one literal word). Zero deps, keyword fallback if it errors.
+
+> Why not neural embeddings? This Ollama runs **without `--embeddings`** (enabling it is a
+> gated fleet-wide change), and a paid embedding API is off the $0-local default. The
+> lexical-semantic scorer is the honest $0/no-dep upgrade; swap in Ollama/OpenAI embeddings
+> in `semantic.Scorer` if that changes.
+
 ## Next steps (not done — future work)
 
 - Add an A2A *client-side* helper using the SDK's own `create_client` (proto
diff --git a/cabinet_directory.py b/cabinet_directory.py
index 2cf44c8..4ee6fd6 100644
--- a/cabinet_directory.py
+++ b/cabinet_directory.py
@@ -18,6 +18,7 @@ import re
 import uuid
 from pathlib import Path
 
+import semantic
 import uvicorn
 from starlette.applications import Starlette
 
@@ -65,19 +66,36 @@ def _load_officers() -> list[dict]:
 
 
 OFFICERS = _load_officers()
+_SCORER = semantic.Scorer([o["blob"] for o in OFFICERS])
 
 
 def _tokens(s: str) -> list[str]:
     return [w for w in re.findall(r"[a-z0-9]+", (s or "").lower()) if w not in _STOP and len(w) > 1]
 
 
-def find(query: str, top: int = 3) -> list[tuple[dict, int, list[str]]]:
+def _matched_terms(query: str, officer: dict) -> list[str]:
+    """Which query (or synonym-expanded) terms actually appear in the officer — for the 'why'."""
+    terms = set(_tokens(query))
+    for t in list(terms):
+        terms |= semantic.SYNONYMS.get(t, set())
+    return sorted(t for t in terms if t in officer["blob"])
+
+
+def find(query: str, top: int = 3):
+    """Lexical-semantic ranking (TF-IDF + synonyms + trigram cosine); keyword fallback."""
+    try:
+        scores = _SCORER.score(query)
+        ranked = sorted(zip(OFFICERS, scores), key=lambda x: x[1], reverse=True)
+        out = [(o, s, _matched_terms(query, o)) for o, s in ranked if s > 0.0]
+        if out:
+            return out[:top]
+    except Exception:
+        pass
+    # fallback: raw keyword-hit-count
     q = _tokens(query)
-    scored = []
-    for o in OFFICERS:
-        hits = [w for w in q if w in o["blob"]]
-        if hits:
-            scored.append((o, len(hits), sorted(set(hits))))
+    scored = [(o, float(len([w for w in q if w in o["blob"]])), sorted({w for w in q if w in o["blob"]}))
+              for o in OFFICERS]
+    scored = [x for x in scored if x[1] > 0]
     scored.sort(key=lambda x: x[1], reverse=True)
     return scored[:top]
 
@@ -114,14 +132,14 @@ def handle(text: str) -> str:
             return f"no officer matched {q!r}. Try 'list' to see all."
         lines = [f"best officer(s) for {q!r}:"]
         for o, score, hits in ranked:
-            lines.append(f"  → {o['vp']} (score {score}; matched: {', '.join(hits)})")
+            lines.append(f"  → {o['vp']} (score {score:.3f}; matched: {', '.join(hits) or '—'})")
         return "\n".join(lines)
     # bare query with no verb → treat as find
     ranked = find(cmd)
     if ranked:
         lines = [f"(interpreted as find {cmd!r})"]
         for o, score, hits in ranked:
-            lines.append(f"  → {o['vp']} (score {score}; matched: {', '.join(hits)})")
+            lines.append(f"  → {o['vp']} (score {score:.3f}; matched: {', '.join(hits) or '—'})")
         return "\n".join(lines)
     return f"unknown command {cmd!r} — try 'help'"
 
diff --git a/client_sdk.py b/client_sdk.py
new file mode 100644
index 0000000..a5346a0
--- /dev/null
+++ b/client_sdk.py
@@ -0,0 +1,69 @@
+"""SDK-native A2A client — uses a2a-sdk's own client stack (not raw JSON-RPC).
+
+Contrast with client.py (hand-rolled JSON-RPC over httpx): this drives the official
+a2a-sdk 1.1.2 client — A2ACardResolver to fetch the agent card, ClientFactory.create to
+build a transport-appropriate Client, and the async send_message() streaming iterator.
+The SDK handles the proto SendMessageRequest, the SendMessage method name, and response
+aggregation; we still supply the A2A-Version:1.0 header via the httpx client.
+
+Run (server up): . .venv/bin/activate && A2A_BASE=http://127.0.0.1:41241 python client_sdk.py "hello"
+"""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+import uuid
+
+import httpx
+
+from a2a.client import A2ACardResolver, ClientFactory, ClientConfig
+from a2a.types import SendMessageRequest, Message, Part, Role
+
+BASE = os.environ.get("A2A_BASE", "http://127.0.0.1:41241")
+
+
+def _text_of(resp) -> str | None:
+    """Pull text out of a StreamResponse (oneof message/task/status_update/artifact_update)."""
+    if resp.HasField("message"):
+        return "".join(p.text for p in resp.message.parts if p.text)
+    if resp.HasField("task"):
+        st = resp.task.status
+        if st and st.message:
+            return "".join(p.text for p in st.message.parts if p.text)
+        return f"[task {resp.task.id} state={resp.task.status.state}]"
+    return None
+
+
+async def run(base: str, text: str) -> int:
+    async with httpx.AsyncClient(headers={"A2A-Version": "1.0"}, timeout=30) as hx:
+        card = await A2ACardResolver(hx, base).get_agent_card()
+        print(f"① DISCOVERED (SDK): {card.name} v{card.version}")
+        client = ClientFactory(ClientConfig(httpx_client=hx, streaming=False)).create(card)
+        req = SendMessageRequest(
+            message=Message(
+                message_id=str(uuid.uuid4()),
+                role=Role.ROLE_USER,
+                parts=[Part(text=text)],
+            )
+        )
+        print(f"② SEND (SDK send_message): {text!r}")
+        got = False
+        async for resp in client.send_message(req):
+            out = _text_of(resp)
+            if out is not None:
+                print("③ RESPONSE:\n" + out)
+                got = True
+        if not got:
+            print("③ (no message payload in stream)")
+        close = getattr(client, "close", None)
+        if close:
+            res = close()
+            if asyncio.iscoroutine(res):
+                await res
+    return 0
+
+
+if __name__ == "__main__":
+    text = sys.argv[1] if len(sys.argv) > 1 else "hello"
+    raise SystemExit(asyncio.run(run(BASE, text)))
diff --git a/semantic.py b/semantic.py
new file mode 100644
index 0000000..139650d
--- /dev/null
+++ b/semantic.py
@@ -0,0 +1,100 @@
+"""Lexical-semantic scorer for the cabinet directory `find` — zero-dependency.
+
+NOT neural embeddings: this Ollama was started without `--embeddings` (a gated
+fleet-wide change), and a paid embedding API is off the $0-local default. Instead this
+is a genuine upgrade over raw keyword-hit-count, combining three signals:
+
+  1. TF-IDF term weighting — rare/distinctive words (e.g. "grpc") outweigh common ones
+     (e.g. "management") across the 12 officer docs.
+  2. Synonym expansion — a small domain map so "dns" also matches ssl/cloudflare/mx,
+     "scrape" matches catalog/vendor/import, "secret" matches key/token/credential, etc.
+  3. Char-trigram cosine — morphology-tolerant, so "rotating"/"rotation" hit "rotate".
+
+Final score = cosine(query, officer) over a combined token+trigram TF-IDF space.
+Falls back cleanly if anything is degenerate (empty query → []).
+"""
+from __future__ import annotations
+
+import math
+import re
+from collections import Counter
+
+STOP = {"the", "a", "an", "and", "or", "to", "for", "of", "with", "who", "what", "handles",
+        "handle", "which", "officer", "do", "i", "need", "can", "help", "me", "my", "on",
+        "is", "are", "that", "this", "in", "it", "how", "please"}
+
+# Domain synonym clusters — each token maps to related terms present in officer blobs.
+SYNONYMS: dict[str, set[str]] = {}
+def _cluster(*words):
+    s = set(words)
+    for w in words:
+        SYNONYMS.setdefault(w, set()).update(s)
+_cluster("dns", "ssl", "tls", "cloudflare", "godaddy", "mx", "spf", "dkim", "dmarc", "cert", "domain", "certbot")
+_cluster("scrape", "scraper", "crawl", "catalog", "vendor", "import", "onboard", "shopify")
+_cluster("secret", "secrets", "key", "token", "credential", "credentials", "rotate", "api", "leaked")
+_cluster("security", "breach", "hacked", "intrusion", "backdoor", "firewall", "harden", "cve", "vulnerability")
+_cluster("deploy", "kamatera", "pm2", "ship", "launch", "nginx")
+_cluster("video", "reel", "reels", "hyperframes", "render", "avatar", "narration")
+_cluster("seo", "search", "ranking", "keywords", "aeo")
+_cluster("social", "instagram", "tiktok", "linkedin", "facebook", "pinterest", "youtube", "post")
+_cluster("email", "mailer", "gmail", "george", "inbox", "purelymail")
+_cluster("uptime", "monitor", "canary", "watchdog", "crashed", "down", "health")
+_cluster("directory", "lawyer", "doctor", "animals", "listings")
+_cluster("compliance", "canspam", "legal", "policy", "tcpa", "dnc")
+_cluster("copy", "marketing", "campaign", "brand", "content")
+
+
+def _tokens(text: str) -> list[str]:
+    return [w for w in re.findall(r"[a-z0-9]+", (text or "").lower()) if w not in STOP and len(w) > 1]
+
+
+def _trigrams(text: str) -> list[str]:
+    out = []
+    for w in _tokens(text):
+        p = f"^{w}$"
+        out.extend(p[i:i + 3] for i in range(len(p) - 2))
+    return out
+
+
+def _features(text: str) -> Counter:
+    """Combined bag of word tokens (prefixed t:) + char trigrams (g:)."""
+    f = Counter()
+    for w in _tokens(text):
+        f[f"t:{w}"] += 1
+    for g in _trigrams(text):
+        f[f"g:{g}"] += 1
+    return f
+
+
+class Scorer:
+    """Fit IDF over the officer docs once; score queries against them."""
+
+    def __init__(self, docs: list[str]):
+        self.doc_feats = [_features(d) for d in docs]
+        n = len(docs) or 1
+        df = Counter()
+        for f in self.doc_feats:
+            for k in f:
+                df[k] += 1
+        self.idf = {k: math.log((n + 1) / (c + 1)) + 1 for k, c in df.items()}
+        self.doc_vecs = [self._weight(f) for f in self.doc_feats]
+
+    def _weight(self, feats: Counter) -> dict[str, float]:
+        return {k: v * self.idf.get(k, math.log(len(self.doc_feats) + 1) + 1) for k, v in feats.items()}
+
+    def _expand(self, query: str) -> str:
+        toks = _tokens(query)
+        extra = []
+        for t in toks:
+            extra.extend(SYNONYMS.get(t, ()))
+        return " ".join(toks + extra)
+
+    def score(self, query: str) -> list[float]:
+        qv = self._weight(_features(self._expand(query)))
+        qn = math.sqrt(sum(v * v for v in qv.values())) or 1.0
+        out = []
+        for dv in self.doc_vecs:
+            dn = math.sqrt(sum(v * v for v in dv.values())) or 1.0
+            dot = sum(w * dv.get(k, 0.0) for k, w in qv.items())
+            out.append(dot / (qn * dn))
+        return out

← d13a06a A2A cabinet directory server (list/find/card) + fix block-sc  ·  back to A2a Lab  ·  add blind paraphrase eval harness (eval_find.py) — FALSIFIES c5493c8 →