mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
108 lines
5.2 KiB
Python
108 lines
5.2 KiB
Python
"""Pick at most one skill from the Hanzo skills catalog for an agent's turn: shortlist, then re-check.
|
|
|
|
Run with HANZO_API_KEY set, after `pip install hanzo-kai`.
|
|
"""
|
|
|
|
import collections
|
|
import math
|
|
import re
|
|
import tempfile
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from hanzo_kai import Kai, Choice, Noul, RetryPolicy
|
|
|
|
INDEX = "https://hanzoskills.com/llms.txt"
|
|
CACHE = Path(tempfile.gettempdir()) / "kai-cookbook" / "skills-llms.txt"
|
|
|
|
# Each agent turn with the skills a person would load for it; empty when no skill fits.
|
|
TURNS = [
|
|
("The payment API throws 500s in prod. Set up error tracking so we see stack traces.", {"hanzo-sentry"}),
|
|
("Store our Stripe live key so only the billing service can read it.", {"hanzo-kms"}),
|
|
("Add similarity search over our product embeddings.", {"hanzo-vector", "hanzo-vector-go", "hanzo-datastore"}),
|
|
("Write a browser check that the signup page loads and the form submits.", {"webapp-testing"}),
|
|
("Move our Rails backend onto Go and the Hanzo stack.", {"app-port", "hanzo-base"}),
|
|
("Keep wallet balances in a double-entry ledger.", {"hanzo-ledger", "hanzo-treasury"}),
|
|
("Make an animated GIF for the release announcement in Slack.", {"slack-gif-creator"}),
|
|
("Send the contractor agreement out for electronic signature.", {"hanzo-sign"}),
|
|
("Build an MCP server that exposes our ticket database to agents.", {"mcp-builder", "hanzo-mcp"}),
|
|
("thanks, that fixed it", set()),
|
|
("What time is it in Tokyo right now?", set()),
|
|
("good morning!", set()),
|
|
]
|
|
|
|
|
|
def catalog():
|
|
"""The Hanzo skills and the core skills from the published index: name to description."""
|
|
if not CACHE.exists():
|
|
CACHE.parent.mkdir(exist_ok=True)
|
|
request = urllib.request.Request(INDEX, headers={"User-Agent": "kai-cookbook"})
|
|
CACHE.write_bytes(urllib.request.urlopen(request).read())
|
|
out, section = {}, ""
|
|
for line in CACHE.read_text().splitlines():
|
|
if line.startswith("#"):
|
|
section = line.lstrip("#").strip()
|
|
found = re.match(r"- \[([^\]]+)\]\(https://hanzoskills\.com/[^)]+\): (.+)", line)
|
|
if found and section in ("hanzo", "core"):
|
|
out[found[1]] = found[2]
|
|
return out
|
|
|
|
|
|
def words(text):
|
|
return re.findall(r"[a-z0-9]+", text.lower())
|
|
|
|
|
|
def lexical(turn, skills, k=5):
|
|
"""The k skills BM25 ranks highest for the turn, over name and description."""
|
|
bags = {name: collections.Counter(words(name.replace("-", " ") + " " + text)) for name, text in skills.items()}
|
|
df = collections.Counter(w for bag in bags.values() for w in bag)
|
|
n, mean = len(bags), sum(sum(b.values()) for b in bags.values()) / len(bags)
|
|
|
|
def bm25(bag):
|
|
size = sum(bag.values())
|
|
return sum(
|
|
math.log(1 + (n - df[w] + 0.5) / (df[w] + 0.5)) * bag[w] * 2.2 / (bag[w] + 1.2 * (0.25 + 0.75 * size / mean))
|
|
for w in set(words(turn))
|
|
if w in bag
|
|
)
|
|
|
|
return sorted(bags, key=lambda name: bm25(bags[name]), reverse=True)[:k]
|
|
|
|
|
|
def pick(shortlist, relevance, threshold):
|
|
"""The shortlisted skill the re-check rates highest, or None when none clears the threshold."""
|
|
best = max(shortlist, key=relevance.get)
|
|
return best if relevance[best] >= threshold else None
|
|
|
|
|
|
skills = catalog()
|
|
choose = Choice(instructions="Which skill does this task need?", criteria={name: text[:200] for name, text in skills.items()})
|
|
rows = []
|
|
with Kai(retry=RetryPolicy(max_retries=20)) as kai:
|
|
for turn, gold in TURNS:
|
|
ranked = kai.decide({"task": turn}, {"skill": choose}).choices["skill"].probabilities
|
|
by_kai = sorted(ranked, key=ranked.get, reverse=True)
|
|
by_words = lexical(turn, skills)
|
|
both = list(dict.fromkeys(by_kai[:5] + by_words))
|
|
check = {
|
|
f"s{i}": Noul(instructions={"passage": f"{name}: {skills[name]}", "question": "Does `passage` help answer `query`?"})
|
|
for i, name in enumerate(both)
|
|
}
|
|
answers = kai.decide({"query": turn}, check).nouls
|
|
relevance = {name: answers[f"s{i}"].noul for i, name in enumerate(both)}
|
|
rows.append((gold, by_kai, by_words, relevance))
|
|
where = min((by_kai.index(g) + 1 for g in gold), default="-")
|
|
print(f"{turn[:64]:64} gold: {', '.join(sorted(gold)) or 'none'}")
|
|
print(f" Kai's choice over {len(skills)}: gold ranked {where}; first {by_kai[0]}")
|
|
print(f" BM25 top 5: {', '.join(by_words)}")
|
|
print(" re-check, highest: " + ", ".join(f"{n} {p:.2f}" for n, p in sorted(relevance.items(), key=lambda kv: -kv[1])[:3]))
|
|
|
|
fits = [r for r in rows if r[0]]
|
|
print(f"\ngold in the top 5, of {len(fits)} turns a skill fits: Kai's choice {sum(bool(r[0] & set(r[1][:5])) for r in fits)}, BM25 {sum(bool(r[0] & set(r[2])) for r in fits)}")
|
|
print(f"right, of {len(rows)} turns (no skill is right when none fits):")
|
|
print(f" Kai's choice alone, first skill: {sum(r[1][0] in r[0] for r in rows)}")
|
|
print(f" BM25 alone, first skill: {sum(r[2][0] in r[0] for r in rows)}")
|
|
for name, column in (("Kai's top 5, re-checked", 1), ("BM25 top 5, re-checked", 2)):
|
|
right = [sum((pick(r[column][:5], r[3], t) in r[0]) if r[0] else pick(r[column][:5], r[3], t) is None for r in rows) for t in (0.4, 0.5)]
|
|
print(f" {name}: {right[0]} keeping p >= 0.4, {right[1]} keeping p >= 0.5")
|