← 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
M README.mdM cabinet_directory.pyA client_sdk.pyA semantic.py
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 →