mirror of
https://github.com/hanzo-js/kai.git
synced 2026-10-30 15:04:57 +00:00
105 lines
5.8 KiB
TypeScript
105 lines
5.8 KiB
TypeScript
// An ask gate for a community channel: answered above, answered in the docs, for a person, or too vague. Replies are templates.
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { Kai, choice } from "@hanzo/kai";
|
|
|
|
const DOCS = "https://docs.hanzo.ai";
|
|
const K = 6; // shortlisted pages per question
|
|
const FLOOR = 0.5; // a route or a citation below this confidence goes to a person
|
|
|
|
type Passage = { id: string; kind: "message" | "page"; title: string; url: string; text: string };
|
|
|
|
// The channel, oldest first. `want` marks a message to gate and the route its author expects.
|
|
const CHANNEL: { id: string; who: string; text: string; want?: string }[] = [
|
|
{ id: "m1", who: "rosa", text: "Does Kai write text, or does it only answer the questions I send?" },
|
|
{ id: "m2", who: "dev", text: "Only answers: a probability for every option you give it. It generates nothing, so output tokens are always 0." },
|
|
{ id: "m3", who: "sam", text: "What does a Kai call cost?", want: "docs" },
|
|
{ id: "m4", who: "ines", text: "Can Kai also write the reply to my customer?", want: "answered" },
|
|
{ id: "m5", who: "kofi", text: "How do I rotate an API key?", want: "docs" },
|
|
{ id: "m6", who: "lee", text: "Our org was charged twice in August. Can someone refund one of the charges?", want: "person" },
|
|
{ id: "m7", who: "ana", text: "it's broken again", want: "context" },
|
|
{ id: "m8", who: "omar", text: "My /v1/decisions request comes back 402. What does that mean?", want: "docs" },
|
|
];
|
|
|
|
const route = choice("What should happen to `question`?", {
|
|
answered: "a message in `earlier` already answers it",
|
|
docs: "a page in `docs` answers it",
|
|
person: "it needs someone with access to the account, or a decision only staff can make",
|
|
context: "it is too vague to answer; the asker has to say more",
|
|
});
|
|
|
|
// Every page llms.txt lists is one passage: its title and its one-line description.
|
|
async function pages(): Promise<Passage[]> {
|
|
const dir = join(tmpdir(), "kai-cookbook");
|
|
if (!existsSync(join(dir, "llms.txt"))) {
|
|
const res = await fetch(`${DOCS}/llms.txt`);
|
|
if (!res.ok) throw new Error(`llms.txt: HTTP ${res.status}`);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, "llms.txt"), await res.text());
|
|
}
|
|
return readFileSync(join(dir, "llms.txt"), "utf8").split("\n").flatMap((line) => {
|
|
const m = line.match(/^- \[(.+?)\]\((\/[^)]*)\)(?::\s*(.*))?$/);
|
|
return m ? [{ id: "", kind: "page" as const, title: m[1], url: DOCS + m[2], text: m[3] ?? "" }] : [];
|
|
});
|
|
}
|
|
|
|
// The shortlist: BM25 over each page's title and description.
|
|
const STOP = new Set("a an and are as at be but by can do does for from how i in is it its me my of on or our so that the this to what when which who why will with you your".split(" "));
|
|
const words = (s: string) => (s.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter((w) => !STOP.has(w)).map((w) => w.replace(/s$/, ""));
|
|
function index(corpus: Passage[]) {
|
|
const docs = corpus.map((p) => words(`${p.title} ${p.text}`));
|
|
const df = new Map<string, number>();
|
|
for (const d of docs) for (const w of new Set(d)) df.set(w, (df.get(w) ?? 0) + 1);
|
|
const avg = docs.reduce((n, d) => n + d.length, 0) / docs.length;
|
|
return (query: string): Passage[] => {
|
|
const q = new Set(words(query));
|
|
const bm25 = (d: string[]) => [...q].reduce((s, w) => {
|
|
const f = d.filter((x) => x === w).length, n = df.get(w) ?? 0;
|
|
return f ? s + Math.log(1 + (docs.length - n + 0.5) / (n + 0.5)) * (f * 2.2) / (f + 1.2 * (0.25 + 0.75 * d.length / avg)) : s;
|
|
}, 0);
|
|
return corpus.map((p, i) => [p, bm25(docs[i])] as const).filter(([, s]) => s > 0)
|
|
.sort((x, y) => y[1] - x[1]).slice(0, K).map(([p], i) => ({ ...p, id: `d${i + 1}` }));
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
// Each 429 waits out its Retry-After, up to 10 times: a run of calls can meet the rate limit.
|
|
const kai = new Kai({ retry: { maxRetries: 10 } });
|
|
const search = index(await pages());
|
|
let asked = 0, matched = 0;
|
|
for (const [n, msg] of CHANNEL.entries()) {
|
|
if (!msg.want) continue;
|
|
const earlier: Passage[] = CHANNEL.slice(0, n).map((m) => ({ id: m.id, kind: "message", title: m.who, url: "", text: m.text }));
|
|
const found = search(msg.text);
|
|
const passages = [...earlier, ...found];
|
|
const source = choice("Which passage answers `question`?", {
|
|
...Object.fromEntries(passages.map((p) => [p.id, `${p.title}: ${p.text}`.slice(0, 200)])),
|
|
none: "no passage answers it",
|
|
});
|
|
const state = {
|
|
question: msg.text,
|
|
earlier: earlier.map((m) => `${m.id} ${m.title}: ${m.text}`),
|
|
docs: found.map((d) => `${d.id} ${d.title} (${d.url}): ${d.text}`),
|
|
};
|
|
const { route: r, source: s } = (await kai.decide({ state, questions: { route, source } })).answers;
|
|
const cited = passages.find((p) => p.id === s.choice);
|
|
const reply =
|
|
r.confidence < FLOOR ? "to a person: the route is unsure"
|
|
: r.choice === "person" ? "to a person"
|
|
: r.choice === "context" ? "reply: which call did you make, what did you send, and what came back?"
|
|
: !cited || s.confidence < FLOOR ? "to a person: no passage Kai stands behind"
|
|
: r.choice === "answered" && cited.kind === "message" ? `reply: answered above, see ${cited.id}`
|
|
: r.choice === "docs" && cited.kind === "page" ? `reply: see "${cited.title}", ${cited.url}`
|
|
: "to a person: the route and the citation disagree";
|
|
asked++;
|
|
matched += +(r.choice === msg.want);
|
|
console.log(`${msg.id} ${msg.who}: ${msg.text}`);
|
|
console.log(` route ${r.choice} ${r.confidence.toFixed(2)} (want ${msg.want}); cites ${s.choice} ${s.confidence.toFixed(2)}${cited ? ` "${cited.title}"` : ""}`);
|
|
console.log(` ${reply}`);
|
|
}
|
|
console.log(`${matched} of ${asked} routes as the author expected.`);
|
|
}
|
|
|
|
main();
|