fast-mode-kit 0.2.0__py3-none-any.whl
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.
- fast_mode_kit-0.2.0.dist-info/METADATA +106 -0
- fast_mode_kit-0.2.0.dist-info/RECORD +39 -0
- fast_mode_kit-0.2.0.dist-info/WHEEL +5 -0
- fast_mode_kit-0.2.0.dist-info/entry_points.txt +2 -0
- fast_mode_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
- fast_mode_kit-0.2.0.dist-info/top_level.txt +1 -0
- fastkit/__init__.py +0 -0
- fastkit/__main__.py +5 -0
- fastkit/checks.py +131 -0
- fastkit/cli.py +190 -0
- fastkit/client.py +77 -0
- fastkit/evals/__init__.py +0 -0
- fastkit/evals/report.py +48 -0
- fastkit/evals/run.py +96 -0
- fastkit/evals/schema.py +50 -0
- fastkit/evals/shop_gold.jsonl +24 -0
- fastkit/examples/__init__.py +0 -0
- fastkit/examples/shop/__init__.py +0 -0
- fastkit/examples/shop/agent.py +104 -0
- fastkit/examples/shop/data.py +32 -0
- fastkit/examples/shop/demo_model.py +69 -0
- fastkit/examples/shop/tools.py +73 -0
- fastkit/hedge.py +84 -0
- fastkit/loop.py +149 -0
- fastkit/mock.py +32 -0
- fastkit/modes.py +20 -0
- fastkit/pipeline.py +56 -0
- fastkit/prompts.py +21 -0
- fastkit/py.typed +0 -0
- fastkit/scaffold/README.md +11 -0
- fastkit/scaffold/agent.py +84 -0
- fastkit/scaffold/gold.jsonl +3 -0
- fastkit/scaffold/tools.py +37 -0
- fastkit/templates/fast_system.md +21 -0
- fastkit/templates/notes.md +9 -0
- fastkit/templates/reader.md +7 -0
- fastkit/templates/writer.md +5 -0
- fastkit/tools.py +65 -0
- fastkit/types.py +47 -0
fastkit/evals/run.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Run a gold set against an agent and print a report.
|
|
2
|
+
|
|
3
|
+
python -m fastkit.evals.run --gold fastkit/evals/shop_gold.jsonl # offline demo model, free
|
|
4
|
+
python -m fastkit.evals.run --gold fastkit/evals/shop_gold.jsonl \
|
|
5
|
+
--base-url http://localhost:8080/v1 --model my-model # your model
|
|
6
|
+
... --price-in 0.25 --price-out 1.25 --spend-cap 0.50 # hosted APIs: cap the spend
|
|
7
|
+
|
|
8
|
+
Run one request per question. Read cost from the response's own usage.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any, Awaitable, Callable
|
|
16
|
+
|
|
17
|
+
from fastkit.checks import ungrounded_figures
|
|
18
|
+
|
|
19
|
+
from .report import markdown
|
|
20
|
+
from .schema import GoldItem, Result, grade
|
|
21
|
+
|
|
22
|
+
Agent = Callable[[str], Awaitable[dict[str, Any]]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_gold(path: str) -> list[GoldItem]:
|
|
26
|
+
with open(path, encoding="utf-8") as f:
|
|
27
|
+
return [GoldItem.model_validate_json(line) for line in f if line.strip()]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def run_eval(items: list[GoldItem], agent_fn: Agent, *, spend_cap_usd: float,
|
|
31
|
+
price_per_mtok: tuple[float, float] | None) -> list[Result]:
|
|
32
|
+
results: list[Result] = []
|
|
33
|
+
spent = 0.0
|
|
34
|
+
for item in items:
|
|
35
|
+
if price_per_mtok and spend_cap_usd > 0 and spent >= spend_cap_usd:
|
|
36
|
+
break
|
|
37
|
+
out = await agent_fn(item.question)
|
|
38
|
+
u = out.get("usage") or {}
|
|
39
|
+
cost = 0.0
|
|
40
|
+
if price_per_mtok:
|
|
41
|
+
cost = (u.get("prompt_tokens", 0) * price_per_mtok[0]
|
|
42
|
+
+ u.get("completion_tokens", 0) * price_per_mtok[1]) / 1e6
|
|
43
|
+
spent += cost
|
|
44
|
+
results.append(Result(
|
|
45
|
+
id=item.id, language=item.language, correct=grade(item, out),
|
|
46
|
+
first_token_s=out.get("first_token_s"), total_s=out.get("total_s", 0.0), cost_usd=cost,
|
|
47
|
+
fell_back=out.get("fell_back"),
|
|
48
|
+
ungrounded=ungrounded_figures(out.get("text") or "", out.get("rows") or [], item.question)))
|
|
49
|
+
return results
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def mock_agent() -> Agent:
|
|
53
|
+
from fastkit.examples.shop.agent import make_registry, run
|
|
54
|
+
from fastkit.examples.shop.demo_model import DemoModel, demo_vocab
|
|
55
|
+
|
|
56
|
+
async def agent(q: str) -> dict[str, Any]:
|
|
57
|
+
reg = make_registry()
|
|
58
|
+
return await run(q, "fast", DemoModel(reg, vocab=demo_vocab()), registry=reg)
|
|
59
|
+
return agent
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def client_agent(base_url: str, model: str, key_env: str, mode: str) -> Agent:
|
|
63
|
+
from fastkit.examples.shop.agent import make_registry, run
|
|
64
|
+
from fastkit.client import ChatClient
|
|
65
|
+
|
|
66
|
+
async def agent(q: str) -> dict[str, Any]:
|
|
67
|
+
reg = make_registry()
|
|
68
|
+
return await run(q, mode, ChatClient(base_url, os.environ.get(key_env), model), registry=reg)
|
|
69
|
+
return agent
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main() -> None:
|
|
73
|
+
ap = argparse.ArgumentParser()
|
|
74
|
+
ap.add_argument("--gold", default="fastkit/evals/shop_gold.jsonl")
|
|
75
|
+
ap.add_argument("--mock", action="store_true")
|
|
76
|
+
ap.add_argument("--base-url")
|
|
77
|
+
ap.add_argument("--model", default="")
|
|
78
|
+
ap.add_argument("--api-key-env", default="FASTKIT_API_KEY")
|
|
79
|
+
ap.add_argument("--mode", default="fast")
|
|
80
|
+
ap.add_argument("--price-in", type=float, help="USD per million input tokens")
|
|
81
|
+
ap.add_argument("--price-out", type=float, help="USD per million output tokens")
|
|
82
|
+
ap.add_argument("--spend-cap", type=float, default=0.50)
|
|
83
|
+
a = ap.parse_args()
|
|
84
|
+
items = load_gold(a.gold)
|
|
85
|
+
if a.base_url and not a.mock:
|
|
86
|
+
agent = client_agent(a.base_url, a.model, a.api_key_env, a.mode)
|
|
87
|
+
else:
|
|
88
|
+
agent = mock_agent()
|
|
89
|
+
price = (a.price_in, a.price_out) if a.price_in is not None and a.price_out is not None else None
|
|
90
|
+
if a.base_url and price is None:
|
|
91
|
+
print("note: no --price-in/--price-out given, so cost is reported as $0 and the cap cannot stop the run")
|
|
92
|
+
print(markdown(asyncio.run(run_eval(items, agent, spend_cap_usd=a.spend_cap, price_per_mtok=price))))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|
fastkit/evals/schema.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Gold items, results and grading."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
_NUM = re.compile(r"-?\d[\d,]*(?:\.\d+)?")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GoldItem(BaseModel):
|
|
14
|
+
id: str
|
|
15
|
+
question: str
|
|
16
|
+
language: str
|
|
17
|
+
expected_tools: list[str] = Field(default_factory=list)
|
|
18
|
+
expected_figures: list[str] = Field(default_factory=list)
|
|
19
|
+
expects_confirm: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Result(BaseModel):
|
|
23
|
+
id: str
|
|
24
|
+
language: str
|
|
25
|
+
correct: bool
|
|
26
|
+
first_token_s: float | None
|
|
27
|
+
total_s: float
|
|
28
|
+
cost_usd: float
|
|
29
|
+
fell_back: str | None
|
|
30
|
+
ungrounded: list[str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _numbers(text: str) -> list[float]:
|
|
34
|
+
out = []
|
|
35
|
+
for m in _NUM.finditer(text):
|
|
36
|
+
try:
|
|
37
|
+
out.append(float(m.group(0).replace(",", "")))
|
|
38
|
+
except ValueError:
|
|
39
|
+
pass
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def grade(item: GoldItem, out: dict[str, Any]) -> bool:
|
|
44
|
+
"""Correct when every expected figure appears (numerically) and a write
|
|
45
|
+
gets its confirm card."""
|
|
46
|
+
if item.expects_confirm and not out.get("confirm"):
|
|
47
|
+
return False
|
|
48
|
+
haystack = (out.get("text") or "") + " " + json.dumps(out.get("confirm") or {})
|
|
49
|
+
have = _numbers(haystack)
|
|
50
|
+
return all(any(abs(h - float(f)) < 1e-6 for h in have) for f in item.expected_figures)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{"id":"en-01","question":"Which product made the most revenue?","language":"en","expected_tools":["rank_products"],"expected_figures":["84.0"]}
|
|
2
|
+
{"id":"en-02","question":"Top 3 products by units sold","language":"en","expected_tools":["rank_products"],"expected_figures":["15","8","7"]}
|
|
3
|
+
{"id":"en-03","question":"How many notebooks are in stock?","language":"en","expected_tools":["product_stock"],"expected_figures":["120"]}
|
|
4
|
+
{"id":"en-04","question":"Stock of caps?","language":"en","expected_tools":["product_stock"],"expected_figures":["0"]}
|
|
5
|
+
{"id":"en-05","question":"Revenue on 2026-11-05","language":"en","expected_tools":["orders_by_day"],"expected_figures":["-7.5"]}
|
|
6
|
+
{"id":"en-06","question":"What did Chen buy and how much did he spend in total?","language":"en","expected_tools":["customer_orders"],"expected_figures":["35.0","48.0"]}
|
|
7
|
+
{"id":"en-07","question":"Lowest selling product by revenue, and the lamp's stock","language":"en","expected_tools":["rank_products","product_stock"],"expected_figures":["22.5","3"]}
|
|
8
|
+
{"id":"en-08","question":"I sold 3 mugs to Ana","language":"en","expected_tools":["record_sale"],"expected_figures":["3","36.0"],"expects_confirm":true}
|
|
9
|
+
{"id":"es-01","question":"¿Qué producto generó más ingresos?","language":"es","expected_tools":["rank_products"],"expected_figures":["84.0"]}
|
|
10
|
+
{"id":"es-02","question":"Los 3 productos más vendidos por unidades","language":"es","expected_tools":["rank_products"],"expected_figures":["15","8","7"]}
|
|
11
|
+
{"id":"es-03","question":"¿Cuántas velas hay en stock?","language":"es","expected_tools":["product_stock"],"expected_figures":["60"]}
|
|
12
|
+
{"id":"es-04","question":"¿Cuántos pedidos hubo el 2026-11-01?","language":"es","expected_tools":["orders_by_day"],"expected_figures":["2"]}
|
|
13
|
+
{"id":"es-05","question":"Ingresos del 2026-11-03","language":"es","expected_tools":["orders_by_day"],"expected_figures":["81.0"]}
|
|
14
|
+
{"id":"es-06","question":"¿Qué compró Dara?","language":"es","expected_tools":["customer_orders"],"expected_figures":["45.0","22.5"]}
|
|
15
|
+
{"id":"es-07","question":"Precio y stock de la tetera (Teapot)","language":"es","expected_tools":["product_stock"],"expected_figures":["35.0","6"]}
|
|
16
|
+
{"id":"es-08","question":"Vendí 2 lámparas (Lamp)","language":"es","expected_tools":["record_sale"],"expected_figures":["2","96.0"],"expects_confirm":true}
|
|
17
|
+
{"id":"ty-01","question":"wich prodct sold the most unitz","language":"typo","expected_tools":["rank_products"],"expected_figures":["15"]}
|
|
18
|
+
{"id":"ty-02","question":"stok of the scraf","language":"typo","expected_tools":["product_stock"],"expected_figures":["15"]}
|
|
19
|
+
{"id":"ty-03","question":"how much revnue on nov 2 2026","language":"typo","expected_tools":["orders_by_day"],"expected_figures":["80.0"]}
|
|
20
|
+
{"id":"ty-04","question":"bens orders pls","language":"typo","expected_tools":["customer_orders"],"expected_figures":["45.0","36.0","15.0"]}
|
|
21
|
+
{"id":"ty-05","question":"lowest revenue product?? ignore the ones with no sales","language":"typo","expected_tools":["rank_products"],"expected_figures":["22.5"]}
|
|
22
|
+
{"id":"ty-06","question":"top seller y su stock","language":"mixed","expected_tools":["rank_products","product_stock"],"expected_figures":["84.0","40"]}
|
|
23
|
+
{"id":"ty-07","question":"pen set stock porfa","language":"mixed","expected_tools":["product_stock"],"expected_figures":["0"]}
|
|
24
|
+
{"id":"ty-08","question":"just sold 1 tea pot","language":"typo","expected_tools":["record_sale"],"expected_figures":["1","35.0"],"expects_confirm":true}
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Example shop agent with a Fast mode.
|
|
2
|
+
|
|
3
|
+
python -m fastkit.examples.shop.agent "top 3 products by revenue" --mock
|
|
4
|
+
python -m fastkit.examples.shop.agent "lowest selling product" \
|
|
5
|
+
--base-url http://localhost:8080/v1 --model my-model
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastkit.checks import safe_filter
|
|
16
|
+
from fastkit.client import ChatClient
|
|
17
|
+
from fastkit.loop import FastConfig, fast_answer
|
|
18
|
+
from fastkit.modes import resolve_mode
|
|
19
|
+
from fastkit.pipeline import pipeline_answer
|
|
20
|
+
from fastkit.prompts import render
|
|
21
|
+
from fastkit.tools import ToolRegistry
|
|
22
|
+
from fastkit.types import Model
|
|
23
|
+
|
|
24
|
+
from .data import connect
|
|
25
|
+
from .tools import build_registry
|
|
26
|
+
|
|
27
|
+
DOMAIN = "a small online shop's products, stock, customers and orders"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_registry() -> ToolRegistry:
|
|
31
|
+
return build_registry(connect())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def run(question: str, mode: str, model: Model, reader: Model | None = None,
|
|
35
|
+
writer: Model | None = None, registry: ToolRegistry | None = None,
|
|
36
|
+
config: FastConfig | None = None) -> dict[str, Any]:
|
|
37
|
+
reg = registry or make_registry()
|
|
38
|
+
tool_list = "\n".join(f"- {t.name}: {t.description}" for t in reg.tools.values())
|
|
39
|
+
trace: dict[str, Any] = {}
|
|
40
|
+
reader, writer = reader or model, writer or model
|
|
41
|
+
|
|
42
|
+
def fallback():
|
|
43
|
+
return pipeline_answer(reader, writer, reg, question,
|
|
44
|
+
reader_prompt=render("reader", domain=DOMAIN),
|
|
45
|
+
writer_prompt=render("writer", domain=DOMAIN), trace=trace)
|
|
46
|
+
|
|
47
|
+
if config is None:
|
|
48
|
+
# Only give up on a silent model if the backup uses a different one.
|
|
49
|
+
config = FastConfig(silence_fallback=reader is not model)
|
|
50
|
+
if resolve_mode(mode) == "fast":
|
|
51
|
+
# Small registry: offer every tool. Large one: offer a likely bundle and
|
|
52
|
+
# let the model reach the rest through find_tools.
|
|
53
|
+
offered = list(reg.tools) if len(reg.tools) <= 8 else reg.find(question, k=5)
|
|
54
|
+
stream = fast_answer(model, reg, question, [], offered,
|
|
55
|
+
system_prompt=render("fast_system", domain=DOMAIN, tool_list=tool_list),
|
|
56
|
+
fallback=fallback, config=config, trace=trace)
|
|
57
|
+
else:
|
|
58
|
+
trace["fell_back"] = None
|
|
59
|
+
stream = fallback()
|
|
60
|
+
|
|
61
|
+
t0 = time.monotonic()
|
|
62
|
+
first = None
|
|
63
|
+
parts: list[str] = []
|
|
64
|
+
usage = None
|
|
65
|
+
async for ev in stream:
|
|
66
|
+
if ev.kind == "token":
|
|
67
|
+
first = first or time.monotonic() - t0
|
|
68
|
+
parts.append(ev.text)
|
|
69
|
+
elif ev.kind == "done" and ev.usage:
|
|
70
|
+
usage = ev.usage
|
|
71
|
+
if first is None and (parts or trace.get("confirm")):
|
|
72
|
+
first = time.monotonic() - t0
|
|
73
|
+
rows = trace.get("rows", [])
|
|
74
|
+
tools_called = trace.get("tools_called", [])
|
|
75
|
+
text = "".join(parts)
|
|
76
|
+
return {"text": safe_filter(text, rows, question) if text else text, "rows": rows,
|
|
77
|
+
"confirm": trace.get("confirm"), "fell_back": trace.get("fell_back"),
|
|
78
|
+
"note": trace.get("note"), "tools_called": tools_called, "first_token_s": first, "total_s": time.monotonic() - t0,
|
|
79
|
+
"usage": usage or trace.get("usage")}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main() -> None:
|
|
83
|
+
ap = argparse.ArgumentParser()
|
|
84
|
+
ap.add_argument("question")
|
|
85
|
+
ap.add_argument("--mode", default="fast")
|
|
86
|
+
ap.add_argument("--mock", action="store_true", help="offline fake model (default if no --base-url)")
|
|
87
|
+
ap.add_argument("--base-url")
|
|
88
|
+
ap.add_argument("--model", default="")
|
|
89
|
+
ap.add_argument("--api-key-env", default="FASTKIT_API_KEY")
|
|
90
|
+
a = ap.parse_args()
|
|
91
|
+
reg = make_registry()
|
|
92
|
+
if a.base_url and not a.mock:
|
|
93
|
+
model: Model = ChatClient(a.base_url, os.environ.get(a.api_key_env), a.model)
|
|
94
|
+
else:
|
|
95
|
+
from .demo_model import DemoModel, demo_vocab
|
|
96
|
+
model = DemoModel(reg, vocab=demo_vocab())
|
|
97
|
+
out = asyncio.run(run(a.question, a.mode, model, registry=reg))
|
|
98
|
+
print(out["text"] or out["confirm"])
|
|
99
|
+
print(f"\n[first token {out['first_token_s'] or 0:.2f}s, total {out['total_s']:.2f}s, "
|
|
100
|
+
f"fallback={out['fell_back']}]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""A tiny in-memory shop: products, customers, orders. Toy data only."""
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
PRODUCTS = [
|
|
5
|
+
("SKU-1001", "Mug", "kitchen", 12.0, 40), ("SKU-1002", "Teapot", "kitchen", 35.0, 6),
|
|
6
|
+
("SKU-1003", "Cap", "apparel", 18.0, 0), ("SKU-1004", "Scarf", "apparel", 22.5, 15),
|
|
7
|
+
("SKU-1005", "Notebook", "stationery", 4.5, 120), ("SKU-1006", "Pen set", "stationery", 9.0, 0),
|
|
8
|
+
("SKU-1007", "Lamp", "home", 48.0, 3), ("SKU-1008", "Candle", "home", 7.5, 60),
|
|
9
|
+
]
|
|
10
|
+
CUSTOMERS = [(1, "Ana"), (2, "Ben"), (3, "Chen"), (4, "Dara")]
|
|
11
|
+
# (order_id, day, customer_id, sku, qty); negative qty = a return
|
|
12
|
+
ORDERS = [
|
|
13
|
+
(1, "2026-11-01", 1, "SKU-1001", 4), (2, "2026-11-01", 2, "SKU-1005", 10),
|
|
14
|
+
(3, "2026-11-02", 3, "SKU-1002", 1), (4, "2026-11-02", 1, "SKU-1008", 6),
|
|
15
|
+
(5, "2026-11-03", 4, "SKU-1004", 2), (6, "2026-11-03", 2, "SKU-1001", 3),
|
|
16
|
+
(7, "2026-11-04", 3, "SKU-1007", 1), (8, "2026-11-04", 4, "SKU-1005", 5),
|
|
17
|
+
(9, "2026-11-05", 1, "SKU-1004", -1), (10, "2026-11-05", 2, "SKU-1008", 2),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def connect() -> sqlite3.Connection:
|
|
22
|
+
conn = sqlite3.connect(":memory:")
|
|
23
|
+
conn.row_factory = sqlite3.Row
|
|
24
|
+
conn.executescript("""
|
|
25
|
+
create table products(sku text primary key, name text, category text, price real, stock int);
|
|
26
|
+
create table customers(id int primary key, name text);
|
|
27
|
+
create table orders(id int primary key, day text, customer_id int, sku text, qty int);
|
|
28
|
+
""")
|
|
29
|
+
conn.executemany("insert into products values (?,?,?,?,?)", PRODUCTS)
|
|
30
|
+
conn.executemany("insert into customers values (?,?)", CUSTOMERS)
|
|
31
|
+
conn.executemany("insert into orders values (?,?,?,?,?)", ORDERS)
|
|
32
|
+
return conn
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""A fake model so the example runs offline with no key. It is NOT smart:
|
|
2
|
+
it picks the best-matching tool and prints the first rows. Swap in a real
|
|
3
|
+
model with --base-url to see real answers."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, AsyncIterator
|
|
9
|
+
|
|
10
|
+
from fastkit.tools import ToolRegistry
|
|
11
|
+
from fastkit.types import Message, StreamEvent, ToolCall
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DemoModel:
|
|
15
|
+
"""vocab: names the fake model may spot in a question (products, customers)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, registry: ToolRegistry, vocab: list[str] | None = None):
|
|
18
|
+
self.registry = registry
|
|
19
|
+
self.vocab = vocab or []
|
|
20
|
+
|
|
21
|
+
def _args(self, tool_name: str, q: str) -> dict:
|
|
22
|
+
tool = self.registry.get(tool_name)
|
|
23
|
+
props = (tool.parameters or {}).get("properties", {}) if tool else {}
|
|
24
|
+
ql = q.lower().replace(" ", "")
|
|
25
|
+
names = [v for v in self.vocab if v.lower().replace(" ", "") in ql]
|
|
26
|
+
nums = [int(n) for n in re.findall(r"(?<![\d-])\d{1,3}(?![\d-])", q)]
|
|
27
|
+
date = re.search(r"\d{4}-\d{2}-\d{2}", q)
|
|
28
|
+
args: dict = {}
|
|
29
|
+
for p in props:
|
|
30
|
+
if p in ("name", "product", "customer") and names:
|
|
31
|
+
args[p] = names[0]
|
|
32
|
+
elif p in ("qty", "n") and nums:
|
|
33
|
+
args[p] = nums[0]
|
|
34
|
+
elif p == "day" and date:
|
|
35
|
+
args[p] = date.group(0)
|
|
36
|
+
return args
|
|
37
|
+
|
|
38
|
+
async def stream(self, messages: list[Message], tools: list[dict] | None = None,
|
|
39
|
+
**kw: Any) -> AsyncIterator[StreamEvent]:
|
|
40
|
+
last = messages[-1]
|
|
41
|
+
if last.role == "user" and tools:
|
|
42
|
+
q = last.content or ""
|
|
43
|
+
found = self.registry.find(q) or ["rank_products"]
|
|
44
|
+
yield StreamEvent(kind="tool_call",
|
|
45
|
+
tool_call=ToolCall(id="demo", name=found[0], arguments=self._args(found[0], q)))
|
|
46
|
+
elif last.role == "user" and (last.content or "").startswith("Question:"):
|
|
47
|
+
rows = json.loads(last.content.split("Rows:\n", 1)[1] or "[]")
|
|
48
|
+
yield StreamEvent(kind="token", text=self._lines(rows))
|
|
49
|
+
elif last.role == "user":
|
|
50
|
+
found = self.registry.find(last.content or "") or ["rank_products"]
|
|
51
|
+
yield StreamEvent(kind="token", text=json.dumps({"parts": [{"tool": found[0], "args": {}}]}))
|
|
52
|
+
else:
|
|
53
|
+
result = json.loads(last.content or "[]")
|
|
54
|
+
if any("error" in r for r in result if isinstance(r, dict)):
|
|
55
|
+
yield StreamEvent(kind="token", text="I need a bit more detail for that one.")
|
|
56
|
+
else:
|
|
57
|
+
yield StreamEvent(kind="token", text=self._lines(result))
|
|
58
|
+
yield StreamEvent(kind="done", usage={"prompt_tokens": 0, "completion_tokens": 0})
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _lines(rows: list[dict]) -> str:
|
|
62
|
+
if not rows:
|
|
63
|
+
return "No data for that."
|
|
64
|
+
return "\n".join(", ".join(f"{k}: {v}" for k, v in r.items()) for r in rows[:3])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def demo_vocab() -> list[str]:
|
|
68
|
+
from .data import CUSTOMERS, PRODUCTS
|
|
69
|
+
return [p[1] for p in PRODUCTS] + [c[1] for c in CUSTOMERS]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Shop tools. Each returns rows with every derived figure computed server-side,
|
|
2
|
+
so the model never has to do arithmetic."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
from fastkit.tools import Tool, ToolRegistry
|
|
8
|
+
|
|
9
|
+
OBJ = lambda props, req=(): {"type": "object", "properties": props, "required": list(req)}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_registry(conn: sqlite3.Connection) -> ToolRegistry:
|
|
13
|
+
q = lambda sql, *a: [dict(r) for r in conn.execute(sql, a).fetchall()]
|
|
14
|
+
|
|
15
|
+
async def rank_products(n: int = 3, by: str = "revenue", order: str = "desc"):
|
|
16
|
+
col = {"revenue": "revenue", "units": "units"}.get(by, "revenue")
|
|
17
|
+
rows = q(f"""select p.sku, p.name, coalesce(sum(o.qty),0) units,
|
|
18
|
+
round(coalesce(sum(o.qty*p.price),0),2) revenue
|
|
19
|
+
from products p left join orders o on o.sku=p.sku group by p.sku""")
|
|
20
|
+
active = [r for r in rows if r["units"] != 0]
|
|
21
|
+
inactive = len(rows) - len(active)
|
|
22
|
+
active.sort(key=lambda r: r[col], reverse=(order != "asc"))
|
|
23
|
+
out = [dict(r, rank=i + 1, measure=col) for i, r in enumerate(active[:n])]
|
|
24
|
+
if order == "asc" and inactive:
|
|
25
|
+
out.append({"inactive_count": inactive, "note": "products with no sales, left out of the ranking"})
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
def find_product(name: str) -> list[dict]:
|
|
29
|
+
# Forgiving lookup: case, spaces and a plural "s" never hide a product.
|
|
30
|
+
key = name.strip().lower()
|
|
31
|
+
rows = q("select sku, name, stock, price from products")
|
|
32
|
+
for cand in (key, key[:-1] if key.endswith("s") else key):
|
|
33
|
+
hit = [r for r in rows if r["name"].lower() == cand or r["sku"].lower() == cand
|
|
34
|
+
or r["name"].lower().replace(" ", "") == cand.replace(" ", "")]
|
|
35
|
+
if hit:
|
|
36
|
+
return hit
|
|
37
|
+
return [r for r in rows if key.rstrip("s") in r["name"].lower()]
|
|
38
|
+
|
|
39
|
+
async def product_stock(name: str):
|
|
40
|
+
return find_product(name) or [{"missing": name, "known_products": [r["name"] for r in q("select name from products")]}]
|
|
41
|
+
|
|
42
|
+
async def orders_by_day(day: str | None = None):
|
|
43
|
+
sql = """select o.day, count(*) orders, sum(o.qty) units, round(sum(o.qty*p.price),2) revenue
|
|
44
|
+
from orders o join products p on p.sku=o.sku {} group by o.day order by o.day"""
|
|
45
|
+
return q(sql.format("where o.day=?"), day) if day else q(sql.format(""))
|
|
46
|
+
|
|
47
|
+
async def customer_orders(customer: str):
|
|
48
|
+
return q("""select c.name customer, o.day, p.name product, o.qty, round(o.qty*p.price,2) amount
|
|
49
|
+
from orders o join customers c on c.id=o.customer_id join products p on p.sku=o.sku
|
|
50
|
+
where lower(c.name)=lower(?) order by o.day""", customer) or [{"missing": customer}]
|
|
51
|
+
|
|
52
|
+
async def record_sale(product: str, qty: int, customer: str | None = None):
|
|
53
|
+
found = find_product(product)
|
|
54
|
+
if not found:
|
|
55
|
+
return [{"missing": product}]
|
|
56
|
+
p = found[0]
|
|
57
|
+
return [{"confirm": {"action": "record_sale", "sku": p["sku"], "product": p["name"], "qty": qty,
|
|
58
|
+
"customer": customer, "amount": round(qty * p["price"], 2)}}]
|
|
59
|
+
|
|
60
|
+
r = ToolRegistry()
|
|
61
|
+
r.register(Tool("rank_products", "rank products by revenue or units sold, highest or lowest first",
|
|
62
|
+
OBJ({"n": {"type": "integer"}, "by": {"type": "string", "enum": ["revenue", "units"]},
|
|
63
|
+
"order": {"type": "string", "enum": ["desc", "asc"]}}), rank_products))
|
|
64
|
+
r.register(Tool("product_stock", "current stock level and price of one product",
|
|
65
|
+
OBJ({"name": {"type": "string"}}, ["name"]), product_stock))
|
|
66
|
+
r.register(Tool("orders_by_day", "order count, units and revenue per day",
|
|
67
|
+
OBJ({"day": {"type": "string", "description": "YYYY-MM-DD"}}), orders_by_day))
|
|
68
|
+
r.register(Tool("customer_orders", "every order a customer placed with amounts",
|
|
69
|
+
OBJ({"customer": {"type": "string"}}, ["customer"]), customer_orders))
|
|
70
|
+
r.register(Tool("record_sale", "record a sale the user reports (sold, selling); shows a confirm card first",
|
|
71
|
+
OBJ({"product": {"type": "string"}, "qty": {"type": "integer"},
|
|
72
|
+
"customer": {"type": "string"}}, ["product", "qty"]), record_sale, kind="propose"))
|
|
73
|
+
return r
|
fastkit/hedge.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Hedged streaming across routes.
|
|
2
|
+
|
|
3
|
+
Lesson: when the primary route is silent, start the SAME model on another
|
|
4
|
+
route rather than falling to a weaker model. The first leg to speak wins.
|
|
5
|
+
A leg that has already started streaming is never cut by a timer.
|
|
6
|
+
|
|
7
|
+
``silence_drop_s`` is a TOTAL budget from the start of the call, shared by
|
|
8
|
+
all legs: with hedge 5 s and drop 15 s, a third leg gets 5 s. Keep
|
|
9
|
+
``hedge_after_s * (len(legs) - 1) < silence_drop_s`` or later legs never start.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from typing import Any, AsyncIterator
|
|
15
|
+
|
|
16
|
+
from .types import Message, Model, StreamEvent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AllLegsSilent(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def hedged_stream(legs: list[Model], messages: list[Message], *, hedge_after_s: float,
|
|
24
|
+
silence_drop_s: float, tools: list[dict] | None = None,
|
|
25
|
+
**kw: Any) -> AsyncIterator[StreamEvent]:
|
|
26
|
+
if not legs:
|
|
27
|
+
raise ValueError("no legs")
|
|
28
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
29
|
+
tasks: list[asyncio.Task] = []
|
|
30
|
+
|
|
31
|
+
async def pump(i: int, leg: Model) -> None:
|
|
32
|
+
try:
|
|
33
|
+
async for ev in leg.stream(messages, tools=tools, **kw):
|
|
34
|
+
await queue.put((i, ev))
|
|
35
|
+
except Exception as exc: # a failed leg is just a silent leg
|
|
36
|
+
await queue.put((i, StreamEvent(kind="error", text=str(exc))))
|
|
37
|
+
await queue.put((i, None))
|
|
38
|
+
|
|
39
|
+
def start(i: int) -> None:
|
|
40
|
+
tasks.append(asyncio.create_task(pump(i, legs[i])))
|
|
41
|
+
|
|
42
|
+
loop = asyncio.get_running_loop()
|
|
43
|
+
t0 = loop.time()
|
|
44
|
+
start(0)
|
|
45
|
+
winner: int | None = None
|
|
46
|
+
finished: set[int] = set()
|
|
47
|
+
try:
|
|
48
|
+
while True:
|
|
49
|
+
if winner is None:
|
|
50
|
+
now = loop.time()
|
|
51
|
+
if now - t0 >= silence_drop_s:
|
|
52
|
+
raise AllLegsSilent(f"no leg spoke within {silence_drop_s}s")
|
|
53
|
+
next_hedge = t0 + hedge_after_s * len(tasks)
|
|
54
|
+
deadline = min(t0 + silence_drop_s,
|
|
55
|
+
next_hedge if len(tasks) < len(legs) else float("inf"))
|
|
56
|
+
try:
|
|
57
|
+
i, ev = await asyncio.wait_for(queue.get(), timeout=max(0.0, deadline - now))
|
|
58
|
+
except asyncio.TimeoutError:
|
|
59
|
+
if len(tasks) < len(legs) and loop.time() >= next_hedge:
|
|
60
|
+
start(len(tasks))
|
|
61
|
+
continue
|
|
62
|
+
else:
|
|
63
|
+
i, ev = await queue.get()
|
|
64
|
+
if ev is None:
|
|
65
|
+
finished.add(i)
|
|
66
|
+
if winner == i:
|
|
67
|
+
return
|
|
68
|
+
if winner is None and len(finished) == len(legs):
|
|
69
|
+
raise AllLegsSilent("every leg ended without output")
|
|
70
|
+
if winner is None and len(tasks) < len(legs):
|
|
71
|
+
start(len(tasks))
|
|
72
|
+
continue
|
|
73
|
+
if winner is None:
|
|
74
|
+
if ev.kind == "error":
|
|
75
|
+
continue
|
|
76
|
+
winner = i
|
|
77
|
+
for j, t in enumerate(tasks):
|
|
78
|
+
if j != i:
|
|
79
|
+
t.cancel()
|
|
80
|
+
if i == winner:
|
|
81
|
+
yield ev
|
|
82
|
+
finally:
|
|
83
|
+
for t in tasks:
|
|
84
|
+
t.cancel()
|