File size: 6,399 Bytes
31b4aff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
"""Agentic discriminator: does REAM pick the right tool with the RIGHT arguments?

The math/factual accuracy test covered the category where PPL predicted no gain.
REAM's biggest PPL gains were tool_use (27->15.6) and coding/agentic -- untested
for capability. This scores tool-call correctness right/wrong: correct function
name AND correct arguments. Argument extraction is exactly what smoothing cannot
fake -- a flatter distribution does not produce '800 by 600' or 'USD->EUR'
correctly. Same scorer for both models.

Usage: python toolcall_eval.py <model-id> <out.json>
"""
import json, re, sys, urllib.request

MODEL = sys.argv[1]
OUT = sys.argv[2] if len(sys.argv) > 2 else None

CATALOG = """Available functions (use EXACTLY these names and argument keys):
- set_timer(minutes)
- convert_currency(amount, from, to)
- book_flight(from_city, to_city, passengers)
- resize_image(width, height)
- set_thermostat(temperature)
- add_to_cart(item, quantity)
- calculate_tip(bill, percent)
- play_song(title, artist)
- get_weather(city)
- get_stock_price(ticker)
- search_products(query, max_price)
- translate(text, target_lang)
- transfer_money(amount, from_account, to_account)
- schedule_meeting(day, time)
- send_invitations(count, event)"""

SYS = (CATALOG + "\n\nCall exactly ONE function. Reply with ONLY a JSON object "
       '{"name": <function>, "arguments": {<args>}} and nothing else. '
       "Use the exact names above and the exact argument values implied by the request.")

# (request, expected_name, {arg: expected}). Numeric/string args are the
# discriminator; free-text args use keyword membership.
ITEMS = [
    ("Set a timer for 25 minutes.", "set_timer", {"minutes": "25"}),
    ("Convert 150 USD to EUR.", "convert_currency", {"amount": "150", "from": "USD", "to": "EUR"}),
    ("Book a flight from Denver to Miami for 3 passengers.", "book_flight",
     {"from_city": "Denver", "to_city": "Miami", "passengers": "3"}),
    ("Resize the image to 800 by 600.", "resize_image", {"width": "800", "height": "600"}),
    ("Set the thermostat to 68 degrees.", "set_thermostat", {"temperature": "68"}),
    ("Add 5 apples to my cart.", "add_to_cart", {"item": "apples", "quantity": "5"}),
    ("Calculate an 18% tip on an 80 dollar bill.", "calculate_tip", {"bill": "80", "percent": "18"}),
    ("Play Bohemian Rhapsody by Queen.", "play_song", {"title": "Bohemian Rhapsody", "artist": "Queen"}),
    ("What's the weather in Paris?", "get_weather", {"city": "Paris"}),
    ("Get the stock price for AAPL.", "get_stock_price", {"ticker": "AAPL"}),
    ("Find headphones under 200 dollars.", "search_products", {"query": "headphones", "max_price": "200"}),
    ("Translate 'good morning' into Spanish.", "translate", {"text": "good morning", "target_lang": "Spanish"}),
    ("Transfer 340 dollars from checking to savings.", "transfer_money",
     {"amount": "340", "from_account": "checking", "to_account": "savings"}),
    ("Schedule a meeting on Tuesday at 3pm.", "schedule_meeting", {"day": "Tuesday", "time": "3"}),
    ("Send 12 invitations to the birthday event.", "send_invitations", {"count": "12", "event": "birthday"}),
]


def ask(prompt, mt=3000):
    body = json.dumps({"model": MODEL,
                       "messages": [{"role": "system", "content": SYS},
                                    {"role": "user", "content": prompt}],
                       "temperature": 0.0, "top_p": 1.0, "top_k": 0, "min_p": 0.0,
                       "repetition_penalty": 1.0, "max_tokens": mt, "stream": False}).encode()
    r = urllib.request.Request("http://localhost:1234/v1/chat/completions", data=body,
                               headers={"Content-Type": "application/json"})
    m = json.load(urllib.request.urlopen(r, timeout=600))["choices"][0]["message"]
    return (m.get("content") or "").strip()


def parse_call(text):
    """Extract {name, arguments} from a JSON object or ```json block."""
    t = re.sub(r"```(?:json)?", "", text)
    # find the outermost {...} that has a "name"
    for m in re.finditer(r"\{.*\}", t, re.S):
        try:
            o = json.loads(m.group(0))
            if isinstance(o, dict) and "name" in o:
                return o.get("name"), (o.get("arguments") or o.get("args") or {})
        except Exception:
            continue
    return None, {}


def norm(v):
    return re.sub(r"(?<=\d)[ ,_](?=\d)", "", str(v)).strip().lower()


def check(name, args, exp_name, exp_args):
    if norm(name) != norm(exp_name):
        return False, "name"
    for k, want in exp_args.items():
        got = args.get(k)
        if got is None:
            # allow the value to appear under any key (arg-naming can differ)
            if any(norm(want) == norm(val) or norm(want) in norm(val)
                   for val in args.values()):
                continue
            return False, f"missing {k}={want}"
        if norm(want) == norm(got) or norm(want) in norm(got):
            continue
        return False, f"{k}: want {want!r} got {got!r}"
    return True, "ok"


def self_test():
    n, a = parse_call('```json\n{"name": "set_timer", "arguments": {"minutes": 25}}\n```')
    assert n == "set_timer" and str(a["minutes"]) == "25"
    ok, _ = check(n, a, "set_timer", {"minutes": "25"}); assert ok
    ok, why = check("set_timer", {"minutes": "30"}, "set_timer", {"minutes": "25"})
    assert not ok, why
    ok, _ = check("resize_image", {"w": "800", "h": "600"}, "resize_image",
                  {"width": "800", "height": "600"})
    assert ok, "value-under-any-key fallback"
    print("[selftest] tool-call parser ok", flush=True)


if __name__ == "__main__":
    self_test()
    if MODEL == "SELFTEST":
        sys.exit(0)
    ok_n = 0; results = []
    for req, en, ea in ITEMS:
        try:
            raw = ask(req)
        except Exception as e:
            print(f"  ERROR {e}", flush=True); continue
        n, a = parse_call(raw)
        ok, why = check(n, a, en, ea)
        ok_n += ok
        results.append({"req": req, "ok": ok, "why": why, "name": n, "args": a})
        print(f"  [{'OK ' if ok else 'XX '}] want {en}({ea}) -> got {n}({a})  {'' if ok else why}", flush=True)
    print(f"\n{MODEL}  TOOL-CALL {ok_n}/{len(ITEMS)}  ({100*ok_n/len(ITEMS):.0f}%)", flush=True)
    if OUT:
        json.dump({"model": MODEL, "score": [ok_n, len(ITEMS)], "results": results},
                  open(OUT, "w"), indent=2)