@alexkroman1/aai-cli 5.13.2 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{_agent-C8Jk-ZcP.mjs → _agent-vGW5SBnX.mjs} +23 -5
- package/dist/_agent.d.ts +18 -0
- package/dist/{_api-client-BX8Qv51n.mjs → _api-client-LgLksMhN.mjs} +45 -8
- package/dist/_api-client.d.ts +39 -0
- package/dist/{_bundler-BuIyDdvs.mjs → _bundler-BapCUUJN.mjs} +42 -14
- package/dist/_bundler.d.ts +43 -5
- package/dist/_cli-common.d.ts +67 -3
- package/dist/{_config-CzwAWOWr.mjs → _config-D_s09e7g.mjs} +36 -18
- package/dist/_deploy.d.ts +2 -5
- package/dist/_dev-restart.d.ts +6 -1
- package/dist/{_dev-server-Ch7K8e8i.mjs → _dev-server-DQ1t9sbw.mjs} +94 -38
- package/dist/_dev-server.d.ts +30 -2
- package/dist/_e2e-test-utils.d.ts +80 -0
- package/dist/_fault-mode.d.ts +175 -0
- package/dist/{_init-CbkrE2wq.mjs → _init-CpISqoR8.mjs} +3 -3
- package/dist/{_output-CC300DzW.mjs → _output-CKkmWs7i.mjs} +8 -1
- package/dist/_output.d.ts +9 -2
- package/dist/{_server-common-61fL__39.mjs → _server-common-6e0QI4mq.mjs} +1 -1
- package/dist/{_slug-api-D30zPmsn.mjs → _slug-api-D2heILuC.mjs} +2 -2
- package/dist/{_studio-D2ciIhbi.mjs → _studio-zAJycZ8j.mjs} +3 -3
- package/dist/{_templates-B1kt3BGt.mjs → _templates-iDGjGiPf.mjs} +4 -6
- package/dist/{_typecheck-gate-4v9UBZHh.mjs → _typecheck-gate-BXvUNnfs.mjs} +1 -1
- package/dist/{_utils-0DHbcqUb.mjs → _utils-B8QmtFhK.mjs} +21 -7
- package/dist/_utils.d.ts +18 -2
- package/dist/{_vite-env-Dg_QlVv0.mjs → _vite-env-BNveawd1.mjs} +31 -1
- package/dist/_vite-env.d.ts +30 -0
- package/dist/build-BO1Ni6oJ.mjs +62 -0
- package/dist/build.d.ts +19 -0
- package/dist/cli-workflow.d.ts +10 -0
- package/dist/cli.d.ts +1 -2
- package/dist/cli.mjs +406 -224
- package/dist/{client-bundler-C9qXL8A2.mjs → client-bundler-B8Q9F8gJ.mjs} +3 -22
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-DCRX3d96.mjs → delete-4I0uNR4a.mjs} +6 -7
- package/dist/delete.d.ts +2 -8
- package/dist/{deploy-DjsEV-40.mjs → deploy-DJ3HRcia.mjs} +13 -14
- package/dist/{dev-CvPH5dbF.mjs → dev-C6SWaAZP.mjs} +3 -3
- package/dist/{eject-LgBRfnbH.mjs → eject-Zrzk5KV3.mjs} +41 -15
- package/dist/eject.d.ts +30 -3
- package/dist/{init-BppxMR8r.mjs → init-BTRGiPQr.mjs} +61 -26
- package/dist/init.d.ts +11 -0
- package/dist/{login-BQQaxJhL.mjs → login-k0Z3VQu9.mjs} +5 -5
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +498 -91
- package/dist/scaffold/package.json +11 -8
- package/dist/scaffold/pnpm-workspace.yaml +32 -0
- package/dist/scaffold/server.mjs +53 -57
- package/dist/{secret-Ds9dvqAj.mjs → secret-DzSQx1Ds.mjs} +6 -3
- package/dist/{storage-DbrQSHFk.mjs → storage-C-Do0mIA.mjs} +18 -6
- package/dist/{studio-GSFd4s5O.mjs → studio-BGVmCA2G.mjs} +10 -10
- package/dist/templates/code-interpreter/agent.ts +0 -2
- package/dist/templates/dispatch-center/agent.test.ts +21 -30
- package/dist/templates/dispatch-center/agent.ts +4 -31
- package/dist/templates/dispatch-center/client.tsx +41 -28
- package/dist/templates/dispatch-center/shared.ts +73 -66
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +13 -15
- package/dist/templates/dispatch-center/tools/incident_create.ts +42 -45
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +60 -62
- package/dist/templates/dispatch-center/tools/incident_get.ts +10 -6
- package/dist/templates/dispatch-center/tools/incident_triage.ts +44 -46
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +40 -42
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +3 -6
- package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +21 -24
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +53 -55
- package/dist/templates/dispatch-center/tools/resources_get_available.ts +3 -5
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +40 -43
- package/dist/templates/embedded-assets/agent.test.ts +83 -0
- package/dist/templates/embedded-assets/agent.ts +1 -48
- package/dist/templates/embedded-assets/shared.ts +17 -0
- package/dist/templates/embedded-assets/system-prompt.md +13 -0
- package/dist/templates/embedded-assets/tools/list_topics.ts +9 -0
- package/dist/templates/embedded-assets/tools/search_knowledge.ts +26 -0
- package/dist/templates/health-assistant/agent.test.ts +169 -0
- package/dist/templates/health-assistant/agent.ts +1 -166
- package/dist/templates/health-assistant/fda.ts +100 -0
- package/dist/templates/health-assistant/tools/check_drug_interaction.ts +59 -0
- package/dist/templates/health-assistant/tools/medication_lookup.ts +31 -0
- package/dist/templates/infocom-adventure/agent.test.ts +205 -0
- package/dist/templates/infocom-adventure/agent.ts +3 -106
- package/dist/templates/infocom-adventure/client.tsx +32 -28
- package/dist/templates/infocom-adventure/shared.ts +36 -16
- package/dist/templates/infocom-adventure/tools/game_state_drop.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_flag.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_get.ts +16 -0
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +14 -0
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +14 -0
- package/dist/templates/infocom-adventure/tools/game_state_restart.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_score.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_take.ts +13 -0
- package/dist/templates/link-digest/agent.test.ts +223 -0
- package/dist/templates/link-digest/agent.ts +79 -0
- package/dist/templates/link-digest/client.tsx +146 -0
- package/dist/templates/link-digest/workflows/digest.ts +248 -0
- package/dist/templates/math-buddy/agent.ts +0 -2
- package/dist/templates/night-owl/agent.test.ts +80 -0
- package/dist/templates/night-owl/agent.ts +1 -68
- package/dist/templates/night-owl/system-prompt.md +10 -0
- package/dist/templates/night-owl/tools/recommend.ts +64 -0
- package/dist/templates/personal-finance/agent.ts +0 -2
- package/dist/templates/pizza-ordering/agent.test.ts +59 -58
- package/dist/templates/pizza-ordering/agent.ts +10 -159
- package/dist/templates/pizza-ordering/client.tsx +4 -4
- package/dist/templates/pizza-ordering/shared.ts +44 -27
- package/dist/templates/pizza-ordering/tools/add_pizza.ts +31 -0
- package/dist/templates/pizza-ordering/tools/place_order.ts +22 -0
- package/dist/templates/pizza-ordering/tools/remove_pizza.ts +22 -0
- package/dist/templates/pizza-ordering/tools/set_customer_name.ts +13 -0
- package/dist/templates/pizza-ordering/tools/update_pizza.ts +28 -0
- package/dist/templates/pizza-ordering/tools/view_order.ts +21 -0
- package/dist/templates/plan-and-execute/agent.test.ts +411 -0
- package/dist/templates/plan-and-execute/agent.ts +23 -0
- package/dist/templates/plan-and-execute/client.tsx +115 -0
- package/dist/templates/plan-and-execute/graph.ts +178 -0
- package/dist/templates/plan-and-execute/prompts.ts +110 -0
- package/dist/templates/plan-and-execute/shared.ts +161 -0
- package/dist/templates/plan-and-execute/system-prompt.md +18 -0
- package/dist/templates/plan-and-execute/tools/plan_status.ts +20 -0
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +63 -0
- package/dist/templates/plan-and-execute/tools/start_plan.ts +51 -0
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +101 -0
- package/dist/templates/recap-workflow/agent.test.ts +762 -0
- package/dist/templates/recap-workflow/agent.ts +95 -0
- package/dist/templates/recap-workflow/shared.ts +43 -0
- package/dist/templates/recap-workflow/tools/cancel_recap.ts +23 -0
- package/dist/templates/recap-workflow/tools/keep_transcript.ts +31 -0
- package/dist/templates/recap-workflow/tools/recap_progress.ts +27 -0
- package/dist/templates/recap-workflow/tools/recap_status.ts +46 -0
- package/dist/templates/recap-workflow/tools/request_recap.ts +43 -0
- package/dist/templates/recap-workflow/workflows/recap.ts +544 -0
- package/dist/templates/recap-workflow/workflows/tokens.ts +26 -0
- package/dist/templates/redline/agent.test.ts +222 -0
- package/dist/templates/redline/agent.ts +89 -0
- package/dist/templates/redline/client.tsx +184 -0
- package/dist/templates/redline/workflows/prompts.ts +68 -0
- package/dist/templates/redline/workflows/redline.ts +258 -0
- package/dist/templates/research-workflow/agent.test.ts +525 -0
- package/dist/templates/research-workflow/agent.ts +94 -0
- package/dist/templates/research-workflow/shared.ts +35 -0
- package/dist/templates/research-workflow/tools/file_it_now.ts +22 -0
- package/dist/templates/research-workflow/tools/request_research.ts +33 -0
- package/dist/templates/research-workflow/tools/research_progress.ts +26 -0
- package/dist/templates/research-workflow/tools/research_status.ts +40 -0
- package/dist/templates/research-workflow/workflows/prompts.ts +143 -0
- package/dist/templates/research-workflow/workflows/research.ts +558 -0
- package/dist/templates/retail/agent.test.ts +319 -256
- package/dist/templates/retail/agent.ts +6 -46
- package/dist/templates/retail/authenticate.ts +2 -2
- package/dist/templates/retail/client.tsx +45 -24
- package/dist/templates/retail/registry.test.ts +40 -25
- package/dist/templates/retail/resolve.test.ts +17 -16
- package/dist/templates/retail/resolve.ts +32 -67
- package/dist/templates/retail/shared.test.ts +21 -19
- package/dist/templates/retail/shared.ts +61 -23
- package/dist/templates/retail/store.test.ts +47 -52
- package/dist/templates/retail/store.ts +92 -79
- package/dist/templates/retail/swap.test.ts +20 -17
- package/dist/templates/retail/swap.ts +7 -14
- package/dist/templates/retail/tools/cancel_pending_order.ts +7 -11
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +26 -14
- package/dist/templates/retail/tools/find_user_id_by_email.ts +5 -9
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +5 -7
- package/dist/templates/retail/tools/get_item_details.ts +7 -8
- package/dist/templates/retail/tools/get_order_details.ts +7 -8
- package/dist/templates/retail/tools/get_product_details.ts +6 -8
- package/dist/templates/retail/tools/get_user_details.ts +6 -8
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -7
- package/dist/templates/retail/tools/modify_pending_order_address.ts +6 -10
- package/dist/templates/retail/tools/modify_pending_order_items.ts +8 -20
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +7 -13
- package/dist/templates/retail/tools/modify_user_address.ts +6 -10
- package/dist/templates/retail/tools/return_delivered_order_items.ts +7 -13
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +1 -1
- package/dist/templates/solo-rpg/agent.test.ts +169 -54
- package/dist/templates/solo-rpg/agent.ts +4 -22
- package/dist/templates/solo-rpg/client.tsx +19 -6
- package/dist/templates/solo-rpg/shared.ts +20 -20
- package/dist/templates/solo-rpg/tools/action_roll.ts +3 -5
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +3 -5
- package/dist/templates/solo-rpg/tools/check_state.ts +7 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +3 -3
- package/dist/templates/solo-rpg/tools/oracle.ts +19 -12
- package/dist/templates/solo-rpg/tools/save_game.ts +11 -8
- package/dist/templates/solo-rpg/tools/setup_character.ts +3 -3
- package/dist/templates/solo-rpg/tools/update_state.ts +13 -13
- package/dist/templates/support-line/agent.test.ts +343 -0
- package/dist/templates/support-line/agent.ts +27 -0
- package/dist/templates/support-line/client.tsx +122 -0
- package/dist/templates/support-line/graph.ts +224 -0
- package/dist/templates/support-line/knowledge.json +65 -0
- package/dist/templates/support-line/prompts.ts +95 -0
- package/dist/templates/support-line/shared.ts +240 -0
- package/dist/templates/support-line/system-prompt.md +17 -0
- package/dist/templates/support-line/tools/answer_question.ts +72 -0
- package/dist/templates/support-line/tools/list_topics.ts +22 -0
- package/dist/templates/support-line/tools/log_ticket.ts +30 -0
- package/dist/templates/transcription-workflow/agent.test.ts +563 -0
- package/dist/templates/transcription-workflow/agent.ts +92 -0
- package/dist/templates/transcription-workflow/client.tsx +234 -0
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +416 -0
- package/dist/templates/transcription-workflow/workflows/wav.ts +314 -0
- package/dist/templates/travel-concierge/agent.test.ts +338 -0
- package/dist/templates/travel-concierge/agent.ts +29 -0
- package/dist/templates/travel-concierge/client.tsx +161 -0
- package/dist/templates/travel-concierge/routing.ts +138 -0
- package/dist/templates/travel-concierge/shared.ts +547 -0
- package/dist/templates/travel-concierge/system-prompt.md +24 -0
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +20 -0
- package/dist/templates/travel-concierge/tools/book_excursion.ts +18 -0
- package/dist/templates/travel-concierge/tools/book_hotel.ts +20 -0
- package/dist/templates/travel-concierge/tools/cancel_action.ts +7 -0
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +16 -0
- package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +7 -0
- package/dist/templates/travel-concierge/tools/confirm_action.ts +7 -0
- package/dist/templates/travel-concierge/tools/lookup_booking.ts +35 -0
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +33 -0
- package/dist/templates/travel-concierge/tools/search_excursions.ts +37 -0
- package/dist/templates/travel-concierge/tools/search_flights.ts +43 -0
- package/dist/templates/travel-concierge/tools/search_hotels.ts +36 -0
- package/dist/templates/travel-concierge/tools/to_car_rental_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_excursion_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_flight_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_hotel_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/update_ticket.ts +22 -0
- package/dist/templates/web-researcher/agent.ts +0 -2
- package/dist/templates/web-researcher/system-prompt.md +10 -0
- package/dist/{test-2LWBcXvY.mjs → test-B6ZBxSpk.mjs} +2 -2
- package/dist/typecheck.mjs +17 -15
- package/dist/worker-bundler-CIdDhZ0c.mjs +398 -0
- package/dist/worker-bundler.d.ts +14 -0
- package/dist/worker-bundler.mjs +1 -108
- package/dist/workflow-B-O97AqI.mjs +125 -0
- package/dist/workflow-bundler.d.ts +133 -0
- package/dist/workflow.d.ts +67 -0
- package/package.json +13 -9
- package/dist/build-DQJ1ZSm9.mjs +0 -39
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
import type { ToolContext } from "@alexkroman1/aai";
|
|
4
|
+
import { createToolContext, runTool, withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import authoredAgent from "./agent.ts";
|
|
7
|
+
import { faqs, searchable } from "./shared.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
11
|
+
*
|
|
12
|
+
* The glob is written HERE rather than reached for from a shared helper because
|
|
13
|
+
* this file SHIPS — a scaffolded project has no `../../_discovery.ts` to import,
|
|
14
|
+
* and `import.meta.glob` is expanded against the file containing it either way.
|
|
15
|
+
*/
|
|
16
|
+
const agentDef = withDiscoveredTools(
|
|
17
|
+
authoredAgent,
|
|
18
|
+
import.meta.glob("./tools/*.ts", { eager: true }),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const run = (name: string, args: Record<string, unknown>, ctx: ToolContext) =>
|
|
22
|
+
runTool(agentDef, name, args, ctx);
|
|
23
|
+
|
|
24
|
+
describe("embedded-assets template", () => {
|
|
25
|
+
test("the JSON asset really is bundled, and the index is built from it", () => {
|
|
26
|
+
// The template's whole subject: an asset imported with an import attribute,
|
|
27
|
+
// resolved at build time rather than fetched. An empty knowledge base would
|
|
28
|
+
// make every search below trivially "not found".
|
|
29
|
+
expect(faqs.length).toBeGreaterThan(0);
|
|
30
|
+
expect(searchable).toHaveLength(faqs.length);
|
|
31
|
+
for (const entry of faqs) {
|
|
32
|
+
expect(entry.question).toBeTypeOf("string");
|
|
33
|
+
expect(entry.answer).toBeTypeOf("string");
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("both tools are discovered from tools/", () => {
|
|
38
|
+
// `agent()` takes no `tools` field: a file in `tools/` IS the tool, and
|
|
39
|
+
// nothing imports it. Discovery is what puts it in front of the model, so a
|
|
40
|
+
// template whose tools are never resolved ships a model with no tools.
|
|
41
|
+
expect(Object.keys(agentDef.tools ?? {}).sort()).toEqual(["list_topics", "search_knowledge"]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("list_topics", () => {
|
|
46
|
+
test("answers with every question in the knowledge base", async () => {
|
|
47
|
+
const topics = await run("list_topics", {}, createToolContext());
|
|
48
|
+
expect(topics).toEqual(faqs.map((f) => f.question));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("search_knowledge", () => {
|
|
53
|
+
test("scores by word overlap rather than by substring", async () => {
|
|
54
|
+
// The reason the tool does not just `includes()` the query: a caller asks a
|
|
55
|
+
// natural question, which is never a substring of an FAQ entry.
|
|
56
|
+
const target = faqs[0];
|
|
57
|
+
if (!target) throw new Error("the knowledge base is empty");
|
|
58
|
+
const words = target.question
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.split(/\W+/)
|
|
61
|
+
.filter((w) => w.length > 2);
|
|
62
|
+
const result = await run(
|
|
63
|
+
"search_knowledge",
|
|
64
|
+
{ query: `hey, could you tell me ${words.join(" ")}?` },
|
|
65
|
+
createToolContext(),
|
|
66
|
+
);
|
|
67
|
+
expect(result).toEqual(target);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a query with no words longer than two characters is refused early", async () => {
|
|
71
|
+
// The guard before the scan: with no scoreable words every entry ties at
|
|
72
|
+
// zero, and returning the first one would be an answer to nothing.
|
|
73
|
+
expect(await run("search_knowledge", { query: "is it a" }, createToolContext())).toEqual({
|
|
74
|
+
result: "No matching FAQ found.",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a query that overlaps nothing reports no match rather than guessing", async () => {
|
|
79
|
+
expect(
|
|
80
|
+
await run("search_knowledge", { query: "zzzqqq wibbleflange" }, createToolContext()),
|
|
81
|
+
).toEqual({ result: "No matching FAQ found." });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -1,54 +1,7 @@
|
|
|
1
|
-
import { agent
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import knowledge from "./knowledge.json" with { type: "json" };
|
|
4
|
-
|
|
5
|
-
type FaqEntry = { question: string; answer: string };
|
|
6
|
-
const faqs: FaqEntry[] = knowledge.faqs;
|
|
7
|
-
|
|
8
|
-
// Search text is static, so it's normalized once at module load — per-call
|
|
9
|
-
// work stays O(query) even when the knowledge base grows.
|
|
10
|
-
const searchable = faqs.map((entry) => ({
|
|
11
|
-
entry,
|
|
12
|
-
text: `${entry.question} ${entry.answer}`.toLowerCase(),
|
|
13
|
-
}));
|
|
1
|
+
import { agent } from "@alexkroman1/aai";
|
|
14
2
|
|
|
15
3
|
export default agent({
|
|
16
4
|
name: "FAQ Bot",
|
|
17
|
-
systemPrompt:
|
|
18
|
-
"You are a friendly FAQ assistant. Answer questions using ONLY the information from your embedded knowledge base. If the user asks something not covered by your knowledge base, say you don't have that information and suggest they check the official documentation.\n\nRules:\n- Keep answers concise and conversational — this is a voice agent\n- Quote the knowledge base accurately, do not embellish\n- If a question is ambiguous, ask the user to clarify\n- Use 'search_knowledge' to find answers to specific questions\n- Use 'list_topics' to see all available FAQ topics\n- Always be helpful and polite",
|
|
19
5
|
greeting:
|
|
20
6
|
"Hi! I'm your FAQ assistant. Ask me anything about the AAI agent framework and I'll look it up in my knowledge base.",
|
|
21
|
-
|
|
22
|
-
tools: {
|
|
23
|
-
list_topics: tool({
|
|
24
|
-
description: "List all available topics in the embedded FAQ knowledge base.",
|
|
25
|
-
async execute() {
|
|
26
|
-
return faqs.map((f) => f.question);
|
|
27
|
-
},
|
|
28
|
-
}),
|
|
29
|
-
|
|
30
|
-
search_knowledge: tool({
|
|
31
|
-
description:
|
|
32
|
-
"Search the embedded FAQ knowledge base for an answer matching the user's question.",
|
|
33
|
-
inputSchema: z.object({
|
|
34
|
-
query: z.string().describe("The user's question to search for"),
|
|
35
|
-
}),
|
|
36
|
-
async execute(args) {
|
|
37
|
-
// Score by word overlap — natural questions rarely match an FAQ
|
|
38
|
-
// entry as an exact substring.
|
|
39
|
-
const words = args.query
|
|
40
|
-
.toLowerCase()
|
|
41
|
-
.split(/\W+/)
|
|
42
|
-
.filter((w) => w.length > 2);
|
|
43
|
-
if (words.length === 0) return { result: "No matching FAQ found." };
|
|
44
|
-
|
|
45
|
-
let best: { entry: FaqEntry; score: number } | null = null;
|
|
46
|
-
for (const { entry, text } of searchable) {
|
|
47
|
-
const score = words.filter((w) => text.includes(w)).length;
|
|
48
|
-
if (score > 0 && (!best || score > best.score)) best = { entry, score };
|
|
49
|
-
}
|
|
50
|
-
return best?.entry ?? { result: "No matching FAQ found." };
|
|
51
|
-
},
|
|
52
|
-
}),
|
|
53
|
-
},
|
|
54
7
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import knowledge from "./knowledge.json" with { type: "json" };
|
|
2
|
+
|
|
3
|
+
export type FaqEntry = { question: string; answer: string };
|
|
4
|
+
|
|
5
|
+
export const faqs: FaqEntry[] = knowledge.faqs;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The searchable index, built once at module load.
|
|
9
|
+
*
|
|
10
|
+
* Search text is static, so normalizing it here keeps per-call work O(query)
|
|
11
|
+
* even when the knowledge base grows — and both tools import this module, so
|
|
12
|
+
* the index is built once for the process rather than once per tool.
|
|
13
|
+
*/
|
|
14
|
+
export const searchable = faqs.map((entry) => ({
|
|
15
|
+
entry,
|
|
16
|
+
text: `${entry.question} ${entry.answer}`.toLowerCase(),
|
|
17
|
+
}));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
You are a friendly FAQ assistant. Answer questions using ONLY the information
|
|
2
|
+
from your embedded knowledge base. If the user asks something not covered by
|
|
3
|
+
your knowledge base, say you don't have that information and suggest they check
|
|
4
|
+
the official documentation.
|
|
5
|
+
|
|
6
|
+
Rules:
|
|
7
|
+
|
|
8
|
+
- Keep answers concise and conversational — this is a voice agent
|
|
9
|
+
- Quote the knowledge base accurately, do not embellish
|
|
10
|
+
- If a question is ambiguous, ask the user to clarify
|
|
11
|
+
- Use 'search_knowledge' to find answers to specific questions
|
|
12
|
+
- Use 'list_topics' to see all available FAQ topics
|
|
13
|
+
- Always be helpful and polite
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { tool } from "@alexkroman1/aai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type FaqEntry, searchable } from "../shared.ts";
|
|
4
|
+
|
|
5
|
+
export default tool({
|
|
6
|
+
description: "Search the embedded FAQ knowledge base for an answer matching the user's question.",
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
query: z.string().describe("The user's question to search for"),
|
|
9
|
+
}),
|
|
10
|
+
async execute(args) {
|
|
11
|
+
// Score by word overlap — natural questions rarely match an FAQ
|
|
12
|
+
// entry as an exact substring.
|
|
13
|
+
const words = args.query
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.split(/\W+/)
|
|
16
|
+
.filter((w) => w.length > 2);
|
|
17
|
+
if (words.length === 0) return { result: "No matching FAQ found." };
|
|
18
|
+
|
|
19
|
+
let best: { entry: FaqEntry; score: number } | null = null;
|
|
20
|
+
for (const { entry, text } of searchable) {
|
|
21
|
+
const score = words.filter((w) => text.includes(w)).length;
|
|
22
|
+
if (score > 0 && (!best || score > best.score)) best = { entry, score };
|
|
23
|
+
}
|
|
24
|
+
return best?.entry ?? { result: "No matching FAQ found." };
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
|
|
4
|
+
import { createToolContext, runTool, withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
5
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
6
|
+
import authoredAgent from "./agent.ts";
|
|
7
|
+
import { excerptAround, type FdaLabel, toDrugInfo } from "./fda.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Only the NETWORK half of `fda.ts` is faked.
|
|
11
|
+
*
|
|
12
|
+
* `fetchFdaLabel` is the one function in this template that leaves the process,
|
|
13
|
+
* so it is the one thing a unit test may not run — everything the tools are
|
|
14
|
+
* actually about (the cross-mention scan, the refuse-on-a-missing-drug rule,
|
|
15
|
+
* the field folding) is pure and stays real. `importActual` rather than a whole
|
|
16
|
+
* module stub for exactly that reason: a fully mocked `fda.ts` would leave
|
|
17
|
+
* `toDrugInfo` returning `undefined` and the tools passing over nothing.
|
|
18
|
+
*/
|
|
19
|
+
vi.mock("./fda.ts", async (importOriginal) => ({
|
|
20
|
+
...(await importOriginal<typeof import("./fda.ts")>()),
|
|
21
|
+
fetchFdaLabel: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const { fetchFdaLabel } = await import("./fda.ts");
|
|
25
|
+
const label = vi.mocked(fetchFdaLabel);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
29
|
+
*
|
|
30
|
+
* The glob is written HERE rather than reached for from a shared helper because
|
|
31
|
+
* this file SHIPS — a scaffolded project has no repo helper to import.
|
|
32
|
+
*/
|
|
33
|
+
const agentDef = withDiscoveredTools(
|
|
34
|
+
authoredAgent,
|
|
35
|
+
import.meta.glob("./tools/*.ts", { eager: true }),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const run = (name: string, args: Record<string, unknown>, ctx: ToolContext = createToolContext()) =>
|
|
39
|
+
runTool(agentDef, name, args, ctx);
|
|
40
|
+
|
|
41
|
+
const IBUPROFEN: FdaLabel = {
|
|
42
|
+
openfda: { generic_name: ["IBUPROFEN"], brand_name: ["Advil"], manufacturer_name: ["Acme"] },
|
|
43
|
+
purpose: ["Pain reliever"],
|
|
44
|
+
warnings: ["Stomach bleeding warning"],
|
|
45
|
+
dosage_and_administration: ["One tablet every 6 hours"],
|
|
46
|
+
adverse_reactions: ["Nausea"],
|
|
47
|
+
drug_interactions: ["Ask a doctor before use if taking WARFARIN, a blood thinner."],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const WARFARIN: FdaLabel = {
|
|
51
|
+
openfda: { generic_name: ["WARFARIN SODIUM"], brand_name: ["Coumadin"] },
|
|
52
|
+
purpose: ["Anticoagulant"],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
// `restoreMocks` restores `vi.spyOn` mocks; it does not clear a `vi.fn()`'s
|
|
57
|
+
// history or its implementation, so an implementation set by one test would
|
|
58
|
+
// otherwise still be installed in the next.
|
|
59
|
+
label.mockReset();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("health-assistant template", () => {
|
|
63
|
+
test("both tools are discovered from tools/", () => {
|
|
64
|
+
// `agent()` takes no `tools` field: a file in `tools/` IS the tool. A
|
|
65
|
+
// template whose tools are never resolved ships a model with none.
|
|
66
|
+
expect(Object.keys(agentDef.tools ?? {}).sort()).toEqual([
|
|
67
|
+
"check_drug_interaction",
|
|
68
|
+
"medication_lookup",
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("medication_lookup", () => {
|
|
74
|
+
test("folds the label's array fields into one flat answer", async () => {
|
|
75
|
+
label.mockResolvedValue(IBUPROFEN);
|
|
76
|
+
const result = await run("medication_lookup", { name: "advil" });
|
|
77
|
+
expect(result).toMatchObject({
|
|
78
|
+
name: "IBUPROFEN",
|
|
79
|
+
brand_names: ["Advil"],
|
|
80
|
+
purpose: "Pain reliever",
|
|
81
|
+
manufacturer: "Acme",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("an unknown drug is a tool FAILURE, not an empty answer", async () => {
|
|
86
|
+
// The model has to be able to tell "no such drug" from "a drug with no
|
|
87
|
+
// warnings", which is why this is a failure rather than a record of "N/A".
|
|
88
|
+
label.mockResolvedValue(null);
|
|
89
|
+
const result = await run("medication_lookup", { name: "sparkleforin" });
|
|
90
|
+
expect(isToolFailure(result) && result.error).toContain("sparkleforin");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a missing section reads N/A rather than undefined", async () => {
|
|
94
|
+
label.mockResolvedValue(WARFARIN);
|
|
95
|
+
expect(await run("medication_lookup", { name: "warfarin" })).toMatchObject({
|
|
96
|
+
warnings: "N/A",
|
|
97
|
+
dosage: "N/A",
|
|
98
|
+
side_effects: "N/A",
|
|
99
|
+
manufacturer: "N/A",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("check_drug_interaction", () => {
|
|
105
|
+
test("reports a cross-mention with the excerpt that justifies it", async () => {
|
|
106
|
+
label.mockImplementation(async (name: string) =>
|
|
107
|
+
name.includes("ibuprofen") ? IBUPROFEN : WARFARIN,
|
|
108
|
+
);
|
|
109
|
+
const result = await run("check_drug_interaction", { drugs: ["ibuprofen", "warfarin"] });
|
|
110
|
+
expect(result).toMatchObject({ interactions_found: 1 });
|
|
111
|
+
const [first] = (result as { interactions: { drug: string; mentions: string }[] }).interactions;
|
|
112
|
+
expect(first).toMatchObject({ drug: "ibuprofen", mentions: "warfarin" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("matches on a BRAND alias, not only the name the caller used", async () => {
|
|
116
|
+
// `toDrugInfo` folds generic and brand names into one alias list precisely
|
|
117
|
+
// so a label naming "Coumadin" still matches a caller who said "warfarin".
|
|
118
|
+
const coumadinMention: FdaLabel = {
|
|
119
|
+
openfda: { generic_name: ["IBUPROFEN"] },
|
|
120
|
+
drug_interactions: ["Do not combine with COUMADIN."],
|
|
121
|
+
};
|
|
122
|
+
label.mockImplementation(async (name: string) =>
|
|
123
|
+
name.includes("ibuprofen") ? coumadinMention : WARFARIN,
|
|
124
|
+
);
|
|
125
|
+
const result = await run("check_drug_interaction", { drugs: ["ibuprofen", "warfarin"] });
|
|
126
|
+
expect(result).toMatchObject({ interactions_found: 1 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a drug that cannot be resolved REFUSES the whole check", async () => {
|
|
130
|
+
// The rule that makes this tool safe to have: a partial answer would read
|
|
131
|
+
// as "no interaction" for the drug that was silently dropped.
|
|
132
|
+
label.mockImplementation(async (name: string) =>
|
|
133
|
+
name.includes("ibuprofen") ? IBUPROFEN : null,
|
|
134
|
+
);
|
|
135
|
+
const result = await run("check_drug_interaction", { drugs: ["ibuprofen", "sparkleforin"] });
|
|
136
|
+
expect(isToolFailure(result) && result.error).toContain("sparkleforin");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("two drugs with no cross-mention are reported as such, with the caveat", async () => {
|
|
140
|
+
label.mockResolvedValue(WARFARIN);
|
|
141
|
+
const result = await run("check_drug_interaction", { drugs: ["warfarin", "aspirin"] });
|
|
142
|
+
expect(result).toMatchObject({ interactions_found: 0 });
|
|
143
|
+
expect((result as { note: string }).note).toContain("does not guarantee");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("whitespace-only names are refused before any lookup happens", async () => {
|
|
147
|
+
// The schema's `min(2)` counts entries, not real names, so the body
|
|
148
|
+
// re-checks after trimming — and must do so BEFORE touching the network.
|
|
149
|
+
const result = await run("check_drug_interaction", { drugs: ["ibuprofen", " "] });
|
|
150
|
+
expect(isToolFailure(result) && result.error).toContain("at least two");
|
|
151
|
+
expect(label).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("fda.ts helpers", () => {
|
|
156
|
+
test("toDrugInfo lowercases every alias so cross-matching is case-blind", () => {
|
|
157
|
+
const info = toDrugInfo("advil", IBUPROFEN);
|
|
158
|
+
expect(info.aliases).toEqual(["advil", "ibuprofen"]);
|
|
159
|
+
expect(info.interactionsText).toContain("warfarin");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("excerptAround ellipsizes only the ends it actually cut", () => {
|
|
163
|
+
const text = `${"a".repeat(300)}warfarin${"b".repeat(300)}`;
|
|
164
|
+
const excerpt = excerptAround(text, "warfarin");
|
|
165
|
+
expect(excerpt.startsWith("…")).toBe(true);
|
|
166
|
+
expect(excerpt.endsWith("…")).toBe(true);
|
|
167
|
+
expect(excerptAround("warfarin", "warfarin")).toBe("warfarin");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -1,173 +1,8 @@
|
|
|
1
|
-
import { agent
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import systemPrompt from "./system-prompt.md?raw";
|
|
4
|
-
|
|
5
|
-
function first(arr: string[] | undefined): string | undefined {
|
|
6
|
-
return arr?.[0];
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
type FdaLabel = Record<string, unknown> & { openfda?: Record<string, string[]> };
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Fetch a drug's FDA label (generic OR brand name match) from openFDA.
|
|
13
|
-
* Returns null when the drug can't be found or the API is unreachable.
|
|
14
|
-
*
|
|
15
|
-
* Memoized per drug name: a voice session naturally asks several questions
|
|
16
|
-
* about the same drugs, and labels are static, so repeats skip the network
|
|
17
|
-
* round-trip. A null result (not found, or a transient network failure) is
|
|
18
|
-
* NOT cached, so the next call retries instead of pinning the failure.
|
|
19
|
-
*/
|
|
20
|
-
const labelCache = new Map<string, Promise<FdaLabel | null>>();
|
|
21
|
-
|
|
22
|
-
function fetchFdaLabel(name: string): Promise<FdaLabel | null> {
|
|
23
|
-
const key = name.toLowerCase();
|
|
24
|
-
let p = labelCache.get(key);
|
|
25
|
-
if (!p) {
|
|
26
|
-
p = fetchFdaLabelUncached(key).then((label) => {
|
|
27
|
-
if (label === null) labelCache.delete(key);
|
|
28
|
-
return label;
|
|
29
|
-
});
|
|
30
|
-
labelCache.set(key, p);
|
|
31
|
-
}
|
|
32
|
-
return p;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function fetchFdaLabelUncached(name: string): Promise<FdaLabel | null> {
|
|
36
|
-
const q = encodeURIComponent(name);
|
|
37
|
-
try {
|
|
38
|
-
const resp = await fetch(
|
|
39
|
-
`https://api.fda.gov/drug/label.json?search=openfda.generic_name:"${q}"+openfda.brand_name:"${q}"&limit=1`,
|
|
40
|
-
);
|
|
41
|
-
if (!resp.ok) return null;
|
|
42
|
-
const raw = (await resp.json()) as { results?: FdaLabel[] };
|
|
43
|
-
return raw.results?.[0] ?? null;
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
type DrugInfo = {
|
|
50
|
-
/** The name the user asked about. */
|
|
51
|
-
name: string;
|
|
52
|
-
/** All known names, lowercased — generic + brands — used for cross-matching. */
|
|
53
|
-
aliases: string[];
|
|
54
|
-
/** The label's "Drug Interactions" section, lowercased. */
|
|
55
|
-
interactionsText: string;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
function toDrugInfo(name: string, label: FdaLabel): DrugInfo {
|
|
59
|
-
const openfda = label.openfda ?? {};
|
|
60
|
-
const generic = openfda.generic_name ?? [];
|
|
61
|
-
const brands = openfda.brand_name ?? [];
|
|
62
|
-
const aliases = [...new Set([name, ...generic, ...brands].map((n) => n.toLowerCase()))];
|
|
63
|
-
const interactionsText = ((label.drug_interactions as string[] | undefined) ?? [])
|
|
64
|
-
.join(" ")
|
|
65
|
-
.toLowerCase();
|
|
66
|
-
return { name, aliases, interactionsText };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Pull a short excerpt around the first mention of `alias` in `text`. */
|
|
70
|
-
function excerptAround(text: string, alias: string): string {
|
|
71
|
-
const idx = text.indexOf(alias);
|
|
72
|
-
const start = Math.max(0, idx - 100);
|
|
73
|
-
const end = Math.min(text.length, idx + alias.length + 200);
|
|
74
|
-
return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
|
|
75
|
-
}
|
|
1
|
+
import { agent } from "@alexkroman1/aai";
|
|
76
2
|
|
|
77
3
|
export default agent({
|
|
78
4
|
name: "Dr. Sage",
|
|
79
|
-
systemPrompt,
|
|
80
5
|
greeting:
|
|
81
6
|
"Hey, I'm Dr. Sage. Try asking me something like, what are the side effects of ibuprofen, can I take aspirin and warfarin together, or calculate my BMI. Just remember, I'm not a real doctor, so always check with your healthcare provider.",
|
|
82
7
|
builtinTools: ["web_search", "run_code"],
|
|
83
|
-
|
|
84
|
-
tools: {
|
|
85
|
-
check_drug_interaction: tool({
|
|
86
|
-
description:
|
|
87
|
-
"Check for interactions between two or more medications using FDA drug label data. Looks up each drug's official label and reports where one drug's Drug Interactions section mentions another. Absence of a mention does not guarantee safety.",
|
|
88
|
-
inputSchema: z.object({
|
|
89
|
-
drugs: z
|
|
90
|
-
.array(z.string().min(1))
|
|
91
|
-
.min(2)
|
|
92
|
-
.describe("Medication names to check, e.g. ['ibuprofen', 'warfarin']"),
|
|
93
|
-
}),
|
|
94
|
-
async execute(args) {
|
|
95
|
-
const names = args.drugs.map((d) => d.trim().toLowerCase()).filter((d) => d.length > 0);
|
|
96
|
-
if (names.length < 2) {
|
|
97
|
-
return { error: "Provide at least two medication names to check." };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const labels = await Promise.all(names.map((n) => fetchFdaLabel(n)));
|
|
101
|
-
const unresolved = names.filter((_, i) => labels[i] === null);
|
|
102
|
-
if (unresolved.length > 0) {
|
|
103
|
-
// Never silently drop a drug from an interaction check — a partial
|
|
104
|
-
// answer would read as "no interaction" for the missing one.
|
|
105
|
-
return {
|
|
106
|
-
error: `Could not find FDA label data for: ${unresolved.join(", ")}. Check the spelling, or try the generic name.`,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// The unresolved check above guarantees every label resolved.
|
|
111
|
-
const drugs = names.map((name, i) => toDrugInfo(name, labels[i]!));
|
|
112
|
-
|
|
113
|
-
const interactions: Array<{ drug: string; mentions: string; excerpt: string }> = [];
|
|
114
|
-
for (const a of drugs) {
|
|
115
|
-
if (!a.interactionsText) continue;
|
|
116
|
-
for (const b of drugs) {
|
|
117
|
-
if (a === b) continue;
|
|
118
|
-
const hit = b.aliases.find((alias) => a.interactionsText.includes(alias));
|
|
119
|
-
if (hit) {
|
|
120
|
-
interactions.push({
|
|
121
|
-
drug: a.name,
|
|
122
|
-
mentions: b.name,
|
|
123
|
-
excerpt: excerptAround(a.interactionsText, hit).slice(0, 400),
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
drugs: names,
|
|
131
|
-
interactions_found: interactions.length,
|
|
132
|
-
interactions: interactions.slice(0, 5),
|
|
133
|
-
note:
|
|
134
|
-
interactions.length === 0
|
|
135
|
-
? "No cross-mentions found in the FDA label Drug Interactions sections. This does not guarantee the combination is safe — confirm with a pharmacist or doctor."
|
|
136
|
-
: "Based on FDA label Drug Interactions sections. Confirm with a pharmacist or doctor.",
|
|
137
|
-
};
|
|
138
|
-
},
|
|
139
|
-
}),
|
|
140
|
-
|
|
141
|
-
medication_lookup: tool({
|
|
142
|
-
description:
|
|
143
|
-
"Look up detailed information about a single medication, including purpose, warnings, dosage, side effects, and manufacturer. Works with both generic and brand names.",
|
|
144
|
-
inputSchema: z.object({
|
|
145
|
-
name: z
|
|
146
|
-
.string()
|
|
147
|
-
.describe("Medication name (generic or brand, e.g. 'ibuprofen' or 'Advil')"),
|
|
148
|
-
}),
|
|
149
|
-
async execute(args) {
|
|
150
|
-
const drug = await fetchFdaLabel(args.name);
|
|
151
|
-
if (!drug) {
|
|
152
|
-
return { error: `No FDA data found for: ${args.name}` };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const openfda = drug.openfda ?? {};
|
|
156
|
-
return {
|
|
157
|
-
name: openfda.generic_name?.[0] ?? args.name,
|
|
158
|
-
brand_names: openfda.brand_name ?? [],
|
|
159
|
-
purpose:
|
|
160
|
-
first(drug.purpose as string[] | undefined) ??
|
|
161
|
-
first(drug.indications_and_usage as string[] | undefined) ??
|
|
162
|
-
"N/A",
|
|
163
|
-
warnings: first(drug.warnings as string[] | undefined)?.slice(0, 500) ?? "N/A",
|
|
164
|
-
dosage:
|
|
165
|
-
first(drug.dosage_and_administration as string[] | undefined)?.slice(0, 500) ?? "N/A",
|
|
166
|
-
side_effects:
|
|
167
|
-
first(drug.adverse_reactions as string[] | undefined)?.slice(0, 500) ?? "N/A",
|
|
168
|
-
manufacturer: openfda.manufacturer_name?.[0] ?? "N/A",
|
|
169
|
-
};
|
|
170
|
-
},
|
|
171
|
-
}),
|
|
172
|
-
},
|
|
173
8
|
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openFDA drug labels, and the one cache both tools read.
|
|
3
|
+
*
|
|
4
|
+
* This module exists because the memoization below has to be shared: with a
|
|
5
|
+
* cache per tool file, a session that looks a drug up and then checks it for
|
|
6
|
+
* interactions pays the network round-trip twice and the "labels are static"
|
|
7
|
+
* argument stops being true.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isToolFailure } from "@alexkroman1/aai";
|
|
11
|
+
import { fetchJson } from "@alexkroman1/aai/tools";
|
|
12
|
+
|
|
13
|
+
export type FdaLabel = Record<string, unknown> & { openfda?: Record<string, string[]> };
|
|
14
|
+
|
|
15
|
+
export function first(arr: string[] | undefined): string | undefined {
|
|
16
|
+
return arr?.[0];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Memoized per drug name: a voice session naturally asks several questions
|
|
21
|
+
* about the same drugs, and labels are static, so repeats skip the network
|
|
22
|
+
* round-trip. A null result (not found, or a transient network failure) is
|
|
23
|
+
* NOT cached, so the next call retries instead of pinning the failure.
|
|
24
|
+
*/
|
|
25
|
+
const labelCache = new Map<string, Promise<FdaLabel | null>>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fetch a drug's FDA label (generic OR brand name match) from openFDA.
|
|
29
|
+
* Returns null when the drug can't be found or the API is unreachable.
|
|
30
|
+
*/
|
|
31
|
+
export function fetchFdaLabel(name: string): Promise<FdaLabel | null> {
|
|
32
|
+
const key = name.toLowerCase();
|
|
33
|
+
let p = labelCache.get(key);
|
|
34
|
+
if (!p) {
|
|
35
|
+
p = fetchFdaLabelUncached(key).then((label) => {
|
|
36
|
+
if (label === null) labelCache.delete(key);
|
|
37
|
+
return label;
|
|
38
|
+
});
|
|
39
|
+
labelCache.set(key, p);
|
|
40
|
+
}
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One openFDA lookup, through the SDK's own REST call rather than a bare
|
|
46
|
+
* `fetch`.
|
|
47
|
+
*
|
|
48
|
+
* `fetchJson` (`@alexkroman1/aai/tools`) is the same implementation behind the
|
|
49
|
+
* model-facing `fetch_json` builtin, so this inherits the three things a bare
|
|
50
|
+
* `fetch` here had none of: a request DEADLINE (`check_drug_interaction` fires
|
|
51
|
+
* a `Promise.all` over one of these per drug the caller named, and a stalled
|
|
52
|
+
* one held the whole tool call open for as long as the far side wanted), a
|
|
53
|
+
* bounded read so an unexpected body cannot be buffered whole, and URL
|
|
54
|
+
* screening on a developer's own machine under `aai dev`.
|
|
55
|
+
*
|
|
56
|
+
* It ANSWERS with `{ error }` rather than throwing for an HTTP failure, which
|
|
57
|
+
* is the builtin contract — narrowed with `isToolFailure` and folded into the
|
|
58
|
+
* same `null` this has always returned. The `catch` still earns its keep: a
|
|
59
|
+
* connection failure, a timeout or a refused URL is a throw.
|
|
60
|
+
*/
|
|
61
|
+
async function fetchFdaLabelUncached(name: string): Promise<FdaLabel | null> {
|
|
62
|
+
const q = encodeURIComponent(name);
|
|
63
|
+
try {
|
|
64
|
+
const raw = await fetchJson<{ results?: FdaLabel[] }>(
|
|
65
|
+
`https://api.fda.gov/drug/label.json?search=openfda.generic_name:"${q}"+openfda.brand_name:"${q}"&limit=1`,
|
|
66
|
+
);
|
|
67
|
+
if (isToolFailure(raw)) return null;
|
|
68
|
+
return raw.results?.[0] ?? null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type DrugInfo = {
|
|
75
|
+
/** The name the user asked about. */
|
|
76
|
+
name: string;
|
|
77
|
+
/** All known names, lowercased — generic + brands — used for cross-matching. */
|
|
78
|
+
aliases: string[];
|
|
79
|
+
/** The label's "Drug Interactions" section, lowercased. */
|
|
80
|
+
interactionsText: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function toDrugInfo(name: string, label: FdaLabel): DrugInfo {
|
|
84
|
+
const openfda = label.openfda ?? {};
|
|
85
|
+
const generic = openfda.generic_name ?? [];
|
|
86
|
+
const brands = openfda.brand_name ?? [];
|
|
87
|
+
const aliases = [...new Set([name, ...generic, ...brands].map((n) => n.toLowerCase()))];
|
|
88
|
+
const interactionsText = ((label.drug_interactions as string[] | undefined) ?? [])
|
|
89
|
+
.join(" ")
|
|
90
|
+
.toLowerCase();
|
|
91
|
+
return { name, aliases, interactionsText };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Pull a short excerpt around the first mention of `alias` in `text`. */
|
|
95
|
+
export function excerptAround(text: string, alias: string): string {
|
|
96
|
+
const idx = text.indexOf(alias);
|
|
97
|
+
const start = Math.max(0, idx - 100);
|
|
98
|
+
const end = Math.min(text.length, idx + alias.length + 200);
|
|
99
|
+
return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
|
|
100
|
+
}
|