{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "c3d3b4e3",
   "metadata": {},
   "source": [
    "# W3 Homework — Search, Then Compute\n",
    "\n",
    "[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/ralbu85/stml_2026/blob/main/lectures/week03/W3_hw_new_tool.ipynb)\n",
    "\n",
    "**Goal.** Give the model two tools at once: a web search that finds a fact, and a small function you write that computes with it. The model has to search first, then call your function with what it found. You write the function, its docstring, and one request of your own.\n",
    "\n",
    "Sections: setup (1) → the pattern: search, then compute (2) → tool 1: currency conversion ✍️ (3) → tool 2: days between two dates ✍️ (4) → tool 3: your own ✍️ (5) → format check and submission (6).\n",
    "\n",
    "Each assignment has two blocks marked `### FILL IN (START) ###` … `### FILL IN (END) ###`: the function, then the tool list that registers it. Only the blocks change. The check in Section 6 tests the *format* of your work (the function exists, its docstring has an `Args:` section, the model searched and then called your tool); whether the values are right is graded after submission by re-running your notebook.\n",
    "\n",
    "**This homework is collected.** Fill in Section 1.3, make the format check pass, and follow Section 6 to submit to the LMS. Due date: see the LMS.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "069adb16",
   "metadata": {},
   "source": [
    "## 1. Setup\n",
    "\n",
    "### 1.1 Installation\n",
    "\n",
    "`ddgs` = keyless web search (DuckDuckGo).\n",
    "\n",
    "*Do:* run the cell (about 30 seconds, once per session).\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8a04a28f",
   "metadata": {},
   "outputs": [],
   "source": [
    "%pip install -q \"aisuite[openai,anthropic]==0.1.14\" ddgs\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "488171cb",
   "metadata": {},
   "source": [
    "### 1.2 API key and model\n",
    "\n",
    "*Do:* paste your key over `PASTE-YOUR-KEY-HERE` and run. Do not share the notebook with the key inside.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b26b9a55",
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "\n",
    "os.environ[\"OPENAI_API_KEY\"] = \"PASTE-YOUR-KEY-HERE\"   # environment variable the client reads\n",
    "\n",
    "MODEL = \"openai:gpt-4o-mini\"   # Anthropic accounts: MODEL = \"anthropic:claude-haiku-4-5\" and set ANTHROPIC_API_KEY instead\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f833fad1",
   "metadata": {},
   "source": [
    "### 1.3 Submission identity\n",
    "\n",
    "*Do:* fill in your name and student ID, run the cell.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "585f7c88",
   "metadata": {},
   "outputs": [],
   "source": [
    "STUDENT_NAME = \"\"\n",
    "STUDENT_ID = \"\"\n",
    "\n",
    "print(\"submitting as:\", STUDENT_NAME or \"?\", STUDENT_ID or \"?\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3825dca4",
   "metadata": {},
   "source": [
    "### 1.4 Client, search tool, and a first call\n",
    "\n",
    "`web_search` is given: it returns the top results as text, or an error string when the search service is unreachable (lab, Section 4).\n",
    "\n",
    "*Do:* run the cell; the last line must print `ready`.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "35034100",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "import aisuite\n",
    "from ddgs import DDGS                    # keyless DuckDuckGo search\n",
    "\n",
    "client = aisuite.Client()\n",
    "\n",
    "def web_search(query: str):\n",
    "    \"\"\"Search the web and return the top five results as title, URL, and snippet.\n",
    "\n",
    "    Args:\n",
    "        query: Search keywords, for example \"first iPhone release year\".\n",
    "    \"\"\"\n",
    "    try:\n",
    "        results = DDGS().text(query, max_results=5)   # a list of dicts with title, href, body\n",
    "        return \"\\n\".join(f\"{r['title']} | {r['href']} | {r['body']}\" for r in results)   # one line per result\n",
    "    except Exception:\n",
    "        return \"error: search unavailable\"\n",
    "\n",
    "messages = [{\"role\": \"user\", \"content\": \"Reply with exactly: ready\"}]\n",
    "response = client.chat.completions.create(model=MODEL, messages=messages, temperature=0.0)\n",
    "response.choices[0].message.content\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e9f9564d",
   "metadata": {},
   "source": [
    "## 2. The Pattern — Search, Then Compute\n",
    "\n",
    "Two tools are registered: `web_search` and the given `years_since(year)`. The question needs a fact (the year) and a computation (years elapsed). The model must call the tools in that order; nothing in the code fixes the order.\n",
    "\n",
    "*Do:* run the three cells. In the trace, find the search result the model read and the year it passed to `years_since`.\n",
    "\n",
    "The calls below use `max_turns=3`: search request → computation request → final\n",
    "answer. This is a model-call upper bound. Tool execution happens between model\n",
    "calls; the final answer also needs a call. If the last response still requests a\n",
    "tool, inspect the history and report the incomplete run before changing the limit.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f2f8c074",
   "metadata": {},
   "outputs": [],
   "source": [
    "from datetime import date\n",
    "\n",
    "def years_since(year: int):\n",
    "    \"\"\"Return how many years have passed since a given year.\n",
    "\n",
    "    Args:\n",
    "        year: A four-digit year, for example 2007.\n",
    "    \"\"\"\n",
    "    return date.today().year - year   # today's year minus the given year\n",
    "\n",
    "years_since(2007)                      # direct test\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9e2818dd",
   "metadata": {},
   "outputs": [],
   "source": [
    "messages = [{\"role\": \"user\", \"content\": \"How many years ago was the first iPhone released? Search for the release year, then compute.\"}]\n",
    "response = client.chat.completions.create(model=MODEL, messages=messages, tools=[web_search, years_since], max_turns=3, temperature=0.0)\n",
    "if getattr(response.choices[0].message, \"tool_calls\", None):\n",
    "    print(\"Turn limit reached before a final answer. Inspect the tool results below.\")\n",
    "else:\n",
    "    print(response.choices[0].message.content)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "52908f71",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title Show the request, result, and answer - run as-is { display-mode: \"form\" }\n",
    "trace_json = json.dumps(response.choices[0].intermediate_messages, default=vars)\n",
    "trace = json.loads(trace_json)\n",
    "for number, step in enumerate(trace, start=1):\n",
    "    print(f\"\\n[{number}] {step['role']} {step.get('name', '')}\")\n",
    "    for call in step.get(\"tool_calls\") or []:\n",
    "        print(\"  Function:\", call[\"function\"][\"name\"])\n",
    "        print(\"  Arguments:\", json.loads(call[\"function\"][\"arguments\"]))\n",
    "    content = step.get(\"content\") or \"\"\n",
    "    if step[\"role\"] == \"tool\":\n",
    "        content = json.loads(content)\n",
    "    print(content)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "86a3a00b",
   "metadata": {},
   "source": [
    "## 3. Tool 1 — Currency Conversion ✍️\n",
    "\n",
    "Write `convert_currency(amount, rate)`: the amount multiplied by the rate, returned as a number. The model finds today's rate by searching and passes it as `rate`.\n",
    "\n",
    "*Do:* write the function and run the cell (direct test). Then add the function to the tool list in the next cell, run it, and read the trace. Expected: a `web_search` call, then a `convert_currency` call whose `rate` came from the search result.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "92b8e5ff",
   "metadata": {},
   "outputs": [],
   "source": [
    "### FILL IN (START) ###\n",
    "# 1. keep the name and the two annotated parameters\n",
    "# 2. docstring: one sentence + an \"Args:\" section with one line per parameter\n",
    "# 3. body: return amount * rate\n",
    "def convert_currency(amount: float, rate: float):\n",
    "    \"\"\"\"\"\"\n",
    "    return None\n",
    "### FILL IN (END) ###\n",
    "\n",
    "convert_currency(500, 1400)   # direct test: 700000\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1e734e26",
   "metadata": {},
   "outputs": [],
   "source": [
    "### FILL IN (START) ###\n",
    "# add your function to the tool list, next to web_search\n",
    "tools_1 = [web_search]\n",
    "### FILL IN (END) ###\n",
    "\n",
    "messages = [{\"role\": \"user\", \"content\": \"How much is 500 US dollars in Korean won at today's exchange rate? Search for the rate first, then convert.\"}]\n",
    "response = client.chat.completions.create(model=MODEL, messages=messages, tools=tools_1, max_turns=3, temperature=0.0)\n",
    "answer_1 = response.choices[0].message.content\n",
    "trace_1 = json.loads(json.dumps(response.choices[0].intermediate_messages, default=vars))   # kept for the check\n",
    "print(answer_1)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9ac3b6f6",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title Show the request, result, and answer - run as-is { display-mode: \"form\" }\n",
    "trace_json = json.dumps(response.choices[0].intermediate_messages, default=vars)\n",
    "trace = json.loads(trace_json)\n",
    "for number, step in enumerate(trace, start=1):\n",
    "    print(f\"\\n[{number}] {step['role']} {step.get('name', '')}\")\n",
    "    for call in step.get(\"tool_calls\") or []:\n",
    "        print(\"  Function:\", call[\"function\"][\"name\"])\n",
    "        print(\"  Arguments:\", json.loads(call[\"function\"][\"arguments\"]))\n",
    "    content = step.get(\"content\") or \"\"\n",
    "    if step[\"role\"] == \"tool\":\n",
    "        content = json.loads(content)\n",
    "    print(content)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b1203205",
   "metadata": {},
   "source": [
    "## 4. Tool 2 — Days Between Two Dates ✍️\n",
    "\n",
    "Write `days_between(start, end)`: the number of days from one ISO date (`\"2024-07-26\"`) to another. `date.fromisoformat` turns such a string into a date, and subtracting two dates gives an object whose `.days` is the number of days.\n",
    "\n",
    "*Do:* write the function and run the cell (direct test). Then add the function to the tool list in the next cell, run it, and read the trace. Expected: the model searches for the two dates, then calls your function with them.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7d3f3af0",
   "metadata": {},
   "outputs": [],
   "source": [
    "### FILL IN (START) ###\n",
    "# 1. keep the name and the two annotated parameters (ISO date strings)\n",
    "# 2. docstring: one sentence + an \"Args:\" section stating the date format\n",
    "# 3. body: (date.fromisoformat(end) - date.fromisoformat(start)).days\n",
    "def days_between(start: str, end: str):\n",
    "    \"\"\"\"\"\"\n",
    "    return None\n",
    "### FILL IN (END) ###\n",
    "\n",
    "days_between(\"2024-07-26\", \"2024-08-11\")   # direct test: 16\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "df5acd4c",
   "metadata": {},
   "outputs": [],
   "source": [
    "### FILL IN (START) ###\n",
    "# add your function to the tool list, next to web_search\n",
    "tools_2 = [web_search]\n",
    "### FILL IN (END) ###\n",
    "\n",
    "messages = [{\"role\": \"user\", \"content\": \"How many days did the Paris 2024 Olympic Games last, from the opening ceremony to the closing ceremony? Search for the two dates, then compute.\"}]\n",
    "response = client.chat.completions.create(model=MODEL, messages=messages, tools=tools_2, max_turns=3, temperature=0.0)\n",
    "answer_2 = response.choices[0].message.content\n",
    "trace_2 = json.loads(json.dumps(response.choices[0].intermediate_messages, default=vars))\n",
    "print(answer_2)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d912745d",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title Show the request, result, and answer - run as-is { display-mode: \"form\" }\n",
    "trace_json = json.dumps(response.choices[0].intermediate_messages, default=vars)\n",
    "trace = json.loads(trace_json)\n",
    "for number, step in enumerate(trace, start=1):\n",
    "    print(f\"\\n[{number}] {step['role']} {step.get('name', '')}\")\n",
    "    for call in step.get(\"tool_calls\") or []:\n",
    "        print(\"  Function:\", call[\"function\"][\"name\"])\n",
    "        print(\"  Arguments:\", json.loads(call[\"function\"][\"arguments\"]))\n",
    "    content = step.get(\"content\") or \"\"\n",
    "    if step[\"role\"] == \"tool\":\n",
    "        content = json.loads(content)\n",
    "    print(content)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "00a6d9be",
   "metadata": {},
   "source": [
    "## 5. Tool 3 — Your Own ✍️\n",
    "\n",
    "Write one more tool and one request that needs a searched fact *and* your computation. Examples: a celebrity's age from a searched birth year; a searched distance converted from kilometers to miles; the total price of tickets from a searched ticket price; the time difference between two searched time zones. Keep the function simple and pure: one or two annotated parameters, a result computed from them, no network, no files.\n",
    "\n",
    "Requirements: every parameter has a type annotation and an `Args:` line; `MY_TOOL` is your function; `MY_REQUEST` is a question that needs both a search and your tool.\n",
    "\n",
    "*Do:* write the function and the request and run the cell. Then add `MY_TOOL` to the tool list in the next cell, run it, and read the trace. The trace must show a `web_search` call and then a call to your tool.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "88bb2119",
   "metadata": {},
   "outputs": [],
   "source": [
    "### FILL IN (START) ###\n",
    "# 1. define your function with annotated parameters and a docstring with an \"Args:\" section\n",
    "# 2. MY_TOOL = your function (the function object, without parentheses)\n",
    "# 3. MY_REQUEST = one question that needs a web search and then your tool\n",
    "def my_function(x: float):\n",
    "    \"\"\"\"\"\"\n",
    "    return None\n",
    "\n",
    "MY_TOOL = my_function\n",
    "MY_REQUEST = \"\"\n",
    "### FILL IN (END) ###\n",
    "\n",
    "print(MY_TOOL.__name__, \"-\", (MY_TOOL.__doc__ or \"\").strip().splitlines()[0] if (MY_TOOL.__doc__ or \"\").strip() else \"(no docstring)\")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0fced18f",
   "metadata": {},
   "outputs": [],
   "source": [
    "### FILL IN (START) ###\n",
    "# add MY_TOOL to the tool list, next to web_search\n",
    "tools_3 = [web_search]\n",
    "### FILL IN (END) ###\n",
    "\n",
    "answer_3 = \"\"\n",
    "trace_3 = []\n",
    "if MY_REQUEST.strip():                   # runs only once the request is written\n",
    "    messages = [{\"role\": \"user\", \"content\": MY_REQUEST}]\n",
    "    response = client.chat.completions.create(model=MODEL, messages=messages, tools=tools_3, max_turns=3, temperature=0.0)\n",
    "    answer_3 = response.choices[0].message.content\n",
    "    trace_3 = json.loads(json.dumps(response.choices[0].intermediate_messages, default=vars))\n",
    "    print(answer_3)\n",
    "else:\n",
    "    print(\"write MY_REQUEST first\")\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "01c166f9",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title Show the request, result, and answer - run as-is { display-mode: \"form\" }\n",
    "for number, step in enumerate(trace_3, start=1):\n",
    "    print(f\"\\n[{number}] {step['role']} {step.get('name', '')}\")\n",
    "    for call in step.get(\"tool_calls\") or []:\n",
    "        print(\"  Function:\", call[\"function\"][\"name\"])\n",
    "        print(\"  Arguments:\", json.loads(call[\"function\"][\"arguments\"]))\n",
    "    content = step.get(\"content\") or \"\"\n",
    "    if step[\"role\"] == \"tool\":\n",
    "        content = json.loads(content)\n",
    "    print(content)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "734a53e6",
   "metadata": {},
   "source": [
    "## 6. Format Check and Submission\n",
    "\n",
    "The cell checks the format of the three tools and whether the model searched and then called each one. It does not check whether the values are right; that is graded after submission by re-running the notebook.\n",
    "\n",
    "*Do:* run the notebook top to bottom, confirm every row reads `PASS`, then download it (**File → Download → Download .ipynb**) and upload the file to the LMS.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "751e76c2",
   "metadata": {},
   "outputs": [],
   "source": [
    "import inspect\n",
    "\n",
    "def call_order(trace):\n",
    "    \"\"\"Trace -> the tool names the model requested, in order.\"\"\"\n",
    "    return [call[\"function\"][\"name\"] for step in trace for call in (step.get(\"tool_calls\") or [])]\n",
    "\n",
    "def search_then(trace, name):\n",
    "    \"\"\"True if web_search was called and the named tool was called after it.\"\"\"\n",
    "    names = call_order(trace)\n",
    "    return \"web_search\" in names and name in names and names.index(\"web_search\") < len(names) - names[::-1].index(name) - 1\n",
    "\n",
    "def documented(fn):\n",
    "    \"\"\"True if the function has a docstring with an Args section and every parameter is annotated.\"\"\"\n",
    "    doc = (fn.__doc__ or \"\").strip()\n",
    "    params = inspect.signature(fn).parameters.values()\n",
    "    return len(doc) >= 30 and \"Args:\" in doc and all(p.annotation is not inspect.Parameter.empty for p in params)\n",
    "\n",
    "completion = {\n",
    "    \"name and student ID filled in (1.3)\":                 bool(STUDENT_NAME.strip()) and bool(STUDENT_ID.strip()),\n",
    "    \"tool 1: docstring with Args, annotated (3)\":          documented(convert_currency),\n",
    "    \"tool 1: search, then convert_currency called (3)\":    search_then(trace_1, \"convert_currency\"),\n",
    "    \"tool 2: docstring with Args, annotated (4)\":          documented(days_between),\n",
    "    \"tool 2: search, then days_between called (4)\":        search_then(trace_2, \"days_between\"),\n",
    "    \"tool 3: docstring with Args, annotated (5)\":          documented(MY_TOOL),\n",
    "    \"tool 3: request written; search, then tool called (5)\": bool(MY_REQUEST.strip()) and search_then(trace_3, MY_TOOL.__name__),\n",
    "}\n",
    "print(\"submitted by:\", STUDENT_NAME or \"?\", STUDENT_ID or \"?\", \"\\n\")\n",
    "for item, ok in completion.items():\n",
    "    print(\"PASS \" if ok else \"FAIL \", item)\n",
    "print(\"\\nHOMEWORK COMPLETE — download and submit\" if all(completion.values()) else \"\\nNOT COMPLETE YET\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e9a24bdd",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "W4: compare advancing this loop with `max_turns=1` calls and delegating the\n",
    "repetition to aisuite with `max_turns=N`.\n"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": [
    {
     "file_id": "https://github.com/ralbu85/stml_2026/blob/main/lectures/week03/W3_hw_new_tool.ipynb"
    }
   ]
  },
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.12"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
