diff --git a/README.md b/README.md index decd6de..bc2c9a0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Ready-to-run applications demonstrating real-world use cases: - **[Disease Information App](docs/examples/disease-qa/)** - Interactive medical information lookup - **[Financial News Tracker](docs/examples/financial-news-tracker/)** - Real-time market analysis - **[Equity Research Brief](docs/examples/equity-research-brief/)** - Agent API + `finance_search` for ticker-level research briefs +- **[Finance Chart (Sandbox)](docs/examples/finance-chart-sandbox/)** - Agent API + `finance_search` + `sandbox` to chart a stock's price history - **[Academic Research Finder](docs/examples/research-finder/)** - Literature discovery and summarization - **[Discord Bot](docs/examples/discord-py-bot/)** - Discord integration example diff --git a/docs/examples/finance-chart-sandbox/README.mdx b/docs/examples/finance-chart-sandbox/README.mdx new file mode 100644 index 0000000..62ee87c --- /dev/null +++ b/docs/examples/finance-chart-sandbox/README.mdx @@ -0,0 +1,234 @@ +--- +title: Finance Chart (Sandbox) +description: Chart a stock's closing-price history with the Perplexity Agent API sandbox tool — fetched and built inside an isolated container as a background task, then rendered locally. +sidebar_position: 8 +keywords: [agent-api, sandbox, code-execution, stock-chart, matplotlib, csv, background] +--- + +# Finance Chart (Sandbox) + +A command-line tool that charts a stock's closing-price history using Perplexity's [Agent API](https://docs.perplexity.ai/docs/agent-api/quickstart) [`sandbox`](https://docs.perplexity.ai/docs/agent-api/tools/sandbox) tool. + +The whole agent loop runs inside **one background Agent API request**: + +1. The model is given the `sandbox` tool — a full agentic Python environment that includes the Perplexity SDK (web search + URL fetch). +2. Inside the sandbox it finds and fetches the ticker's historical daily closing prices, parses them, and prints a clean `date,close` CSV to stdout between sentinel fences. + +The script polls the request to completion, pulls the CSV out of the sandbox's stdout, saves it, and renders the line chart **locally** with matplotlib. + +![AAPL closing price chart, last month](../../static/img/finance-chart-sandbox-aapl.png) + +## How it differs from the docs (important) + +This recipe was built and verified against the live Agent API. A few realities shape the design: + +- **The `sandbox` tool requires a background task.** On the synchronous/streaming path the request is rejected with `streaming failed: ... unknown tool "sandbox"`. You must submit with `background: true` and poll the response by id. This script always does that. +- **`stdout` is nested.** The execution output lives at `sandbox_results.results[].stdout`, not at the top level of the `sandbox_results` item. +- **`finance_search` has no history (current deployment).** The top-level `finance_search` tool returns only the latest quote, so the price *series* is gathered from inside the sandbox using its in-container Perplexity SDK. +- **Sandbox data fetching is best-effort.** Because the sandbox pulls from third-party web sources, requests can be rate-limited. The script retries the whole call a few times (`--attempts`) until it gets a usable CSV. +- **No PNG comes back.** `sandbox_results` carries only text, so the chart is rendered client-side — and you keep a reusable `.csv`. + +The Agent API is called over **raw HTTP** (stdlib `urllib`, no SDK) so the exact request body is visible and the endpoint is configurable. + +## Features + +- One background request orchestrates the sandbox; the script polls it to completion (resilient to transient 5xx) +- Sandbox fetches the price history itself and emits a fenced `date,close` CSV; the script extracts it from `sandbox_results.results[].stdout` (with message-text fallbacks) +- Automatic retries until a usable CSV is parsed +- Renders a clean closing-price line chart with matplotlib (headless `Agg` backend) +- Saves both a reusable CSV and a PNG +- Configurable `--base-url` / `PERPLEXITY_BASE_URL`; reports sandbox invocation count and request cost + +## Prerequisites + +- Python 3.9+ +- A Perplexity API key with Agent API access. The `sandbox` tool is in **preview** — see the [sandbox docs](https://docs.perplexity.ai/docs/agent-api/tools/sandbox) for availability. + +## Installation + +```bash +cd docs/examples/finance-chart-sandbox +pip install -r requirements.txt # matplotlib only — the API is called over raw HTTP +chmod +x finance_chart_sandbox.py +``` + +## API Key Setup + +```bash +export PERPLEXITY_API_KEY="your-api-key-here" +``` + +You can also pass `--api-key`, place the key in a `.pplx_api_key` file, or add a `PERPLEXITY_API_KEY=` / `PPLX_API_KEY=` line to a local `.env`. + +## Quick Start + +Chart Apple's last 6 months of closing prices: + +```bash +./finance_chart_sandbox.py AAPL +``` + +This writes `AAPL_6mo.csv` and `AAPL_6mo.png` to the current directory. + +## Usage + +```bash +./finance_chart_sandbox.py TICKER [--period 6mo] [--start YYYY-MM-DD --end YYYY-MM-DD] \ + [--model MODEL] [--attempts 3] [--max-steps 25] [--poll-timeout 300] \ + [--out-dir DIR] [--base-url URL] [--api-key KEY] [--keep-json] +``` + +### A one-month chart + +```bash +./finance_chart_sandbox.py AAPL --period 1mo +``` + +### An explicit date range, more retries + +```bash +./finance_chart_sandbox.py MSFT --start 2025-01-01 --end 2025-06-30 --attempts 5 +``` + +### Point at a different endpoint + +```bash +PERPLEXITY_BASE_URL=https://api.perplexity.ai ./finance_chart_sandbox.py NVDA +``` + +## Example Output + +``` +[attempt 1/3] Asking the sandbox to fetch AAPL closing prices over the past 1 month... + +Data points: 21 (2026-04-30 → 2026-05-29) +CSV: AAPL_1mo.csv +Chart: AAPL_1mo.png +Sandbox invocations: 8 +Cost: 0.3509 USD +``` + +The CSV (`AAPL_1mo.csv`): + +```csv +date,close +2026-04-30,271.35 +2026-05-01,280.14 +2026-05-04,276.83 +... +``` + +…and `AAPL_1mo.png` is a line chart of `close` over `date`. + +## Web UI (FastAPI + JS) + +A small web app in [`webapp/`](webapp/) puts a **natural-language** front door +on the agent loop: ask *"What was Apple's stock price over the last 6 months?"* +and the model resolves the ticker and period itself, fetches the prices in the +sandbox, and the page charts the result. + +Unlike the CLI (which calls the API over raw HTTP), the web backend uses the +**Perplexity Python SDK** and reuses the CLI module's CSV-extraction and parsing +helpers. It runs a **two-phase** flow, because the `sandbox` tool only runs as a +(non-streamable) background task: + +1. **Data** — a background call (`client.responses.create(..., background=True)` + then `client.responses.retrieve(id)`) where the sandbox resolves the ticker + + period, fetches the prices, and prints a `META`/`CSV` block. +2. **Answer** — a separate **streaming** call (`stream=True`) that writes a short + natural-language analysis of the series, token by token. + +| Endpoint | Purpose | +| --- | --- | +| `POST /api/charts` | Submit a question (`{query, attempts?}`) → returns a `job_id` | +| `GET /api/charts/{job_id}/events` | **Server-Sent Events**: `progress` → `chart` → streamed `token`s → `done` | +| `GET /api/charts/{job_id}/response.json` | The **raw Agent API response** from phase 1 (sandbox code, stdout, usage) | +| `GET /api/charts/{job_id}/csv` | Download the `date,close` CSV | + +The job runs in a worker thread and writes incremental state onto the job; the +SSE endpoint merely *tails* that state, so reconnects never re-run the work. The +frontend (vanilla JS + [Chart.js](https://www.chartjs.org/) from a CDN — no +build step) renders the chart on the `chart` event, appends the streamed +analysis live, and links to the raw JSON for inspection. + +![Finance Chart sandbox web UI](../../static/img/finance-chart-sandbox-ui.png) + +### Run it + +```bash +cd docs/examples/finance-chart-sandbox/webapp +pip install -r requirements.txt # perplexityai + fastapi + uvicorn +export PERPLEXITY_API_KEY="your-api-key-here" # or a .env in this dir +python app.py # serves http://127.0.0.1:8000 +# PORT=8060 python app.py # if 8000 is taken +``` + +Open the page, type a question (or click an example), and hit **Ask**. The +status line updates per attempt while the background sandbox runs (~30–60s). + +## Code Walkthrough + +**1. Submit a background request with the sandbox tool (raw HTTP).** + +```python +payload = { + "model": "openai/gpt-5.5", + "instructions": SYSTEM_PROMPT, # "print the CSV between fences" + "input": "Produce the daily closing-price CSV for AAPL over the past 6 months. ...", + "tools": [{"type": "sandbox"}], + "background": True, # required for the sandbox tool + "max_steps": 25, +} +# POST https://api.perplexity.ai/v1/responses (Authorization: Bearer ) +``` + +**2. Poll the response by id until it completes.** + +```python +# GET https://api.perplexity.ai/v1/responses/{id} +while body["status"] in ("queued", "in_progress"): + time.sleep(3) + body = get(f"/v1/responses/{body['id']}") # tolerate transient 5xx +``` + +**3. Pull the CSV out of the nested sandbox stdout.** + +```python +for item in body["output"]: + if item["type"] == "sandbox_results": + for res in item["results"]: + stdout = res["stdout"] # contains the fenced CSV +``` + +The script searches each `sandbox_results.results[].stdout` for text between the `===CSV_START===` / `===CSV_END===` fences (falling back to the message text and a ```` ```csv ```` block), validates it parses into ≥2 `date,close` rows, and retries the whole call if not. + +**4. Render the chart locally.** The CSV is parsed with the stdlib `csv` module and plotted with matplotlib's headless `Agg` backend. Because the sandbox returns only text, rendering lives on the client side and you keep a tidy `.csv`. + +## Prompting Guidance + +- **Fence the machine-readable output.** Asking the sandbox to wrap the CSV in unique sentinel lines makes extraction robust even when the model adds commentary or debug prints. +- **Tell it to retry sources.** Public price endpoints rate-limit (e.g. Yahoo `429`) or gate behind captchas; instructing the model to try another source on failure improves the hit rate. +- **Forbid fabrication.** The system prompt instructs the model to use only prices it actually retrieved — never to interpolate or estimate. + +## Pricing + +- **`sandbox`**: `$0.03` per container session +- **In-sandbox SDK search queries**: `$0.005` per request (the sandbox issues these to gather the data) +- **Model tokens**: billed separately per Agent API token pricing + +Sandbox invocations are counted under `usage.tool_calls_details.sandbox.invocation`. A typical run here is a few sandbox calls plus a handful of in-sandbox searches. See [Perplexity Pricing](https://docs.perplexity.ai/docs/getting-started/pricing) for current rates. + +## Limitations + +- `sandbox` is in **preview** and must be run as a background task +- Price history is fetched from third-party web sources inside the sandbox, so **data accuracy and availability depend on those sources** — values should be sanity-checked, and obscure/non-US tickers may fail +- Fetching is **best-effort**: rate limits can cause an attempt to return no CSV; the script retries, but a run may still fail (raise `--attempts`) +- Each attempt is a separate billed sandbox session +- This is not investment advice + +## Resources + +- [Sandbox Tool](https://docs.perplexity.ai/docs/agent-api/tools/sandbox) +- [Agent API Quickstart](https://docs.perplexity.ai/docs/agent-api/quickstart) +- [Finance Search Tool](https://docs.perplexity.ai/docs/agent-api/tools/finance-search) +- [Perplexity Python SDK](https://github.com/ppl-ai/perplexity-python) diff --git a/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py b/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py new file mode 100644 index 0000000..f3687e4 --- /dev/null +++ b/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +""" +Finance Chart (Sandbox) - Plot a stock's closing-price history using the +Perplexity Agent API ``sandbox`` tool, then render the chart locally. + +The agent loop runs entirely inside one **background** Agent API request: + + 1. The model is given the ``sandbox`` tool — a full agentic Python + environment that includes the Perplexity SDK (web search + URL fetch). + 2. Inside the sandbox it searches for / fetches the ticker's historical + daily closing prices, parses them, and prints a clean ``date,close`` CSV + to stdout between sentinel fences. + +We poll the request until it completes, pull the CSV out of the sandbox's +stdout (``sandbox_results.results[].stdout``), save it, and render the line +chart locally with matplotlib. + +Why this shape? +- The ``sandbox`` tool is rejected on the synchronous/streaming path + ("streaming failed: ... unknown tool"); it must run with ``background: true`` + and be polled by id. This script always does that. +- ``sandbox_results`` carries only text (code/stdout/stderr) — there is no + binary artifact channel — so the chart is rendered on this side, and you + also keep a reusable ``.csv``. +- Top-level ``finance_search`` returns only the latest quote (no history) on + the current deployment, so the price *series* is gathered from inside the + sandbox. Because that relies on third-party web data, it is best-effort: + the script retries the whole call a few times until it gets a usable CSV. + +The Agent API is called over **raw HTTP** (no SDK) so the request body — and +the sandbox tool in it — is fully visible, and the endpoint is configurable +via ``--base-url`` / ``PERPLEXITY_BASE_URL``. + +Docs: +- Agent API: https://docs.perplexity.ai/docs/agent-api/quickstart +- sandbox: https://docs.perplexity.ai/docs/agent-api/tools/sandbox +- finance_search: https://docs.perplexity.ai/docs/agent-api/tools/finance-search +""" + +import argparse +import csv +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +DEFAULT_BASE_URL = "https://api.perplexity.ai" +RESPONSES_PATH = "/v1/responses" + +CSV_START = "===CSV_START===" +CSV_END = "===CSV_END===" + + +# Friendly --period values mapped to a natural-language phrase the model can +# act on. Anything not in this map is passed through verbatim. +PERIOD_PHRASES: Dict[str, str] = { + "1mo": "the past 1 month", + "3mo": "the past 3 months", + "6mo": "the past 6 months", + "1y": "the past 1 year", + "2y": "the past 2 years", + "5y": "the past 5 years", +} + + +SYSTEM_PROMPT = f"""You run inside a Python sandbox that includes the +`perplexity` SDK (web search and URL fetch) plus pandas and the standard +library. Your job is to produce a CSV of a stock's daily closing prices. + +Approach: +- Use the perplexity SDK to obtain the daily closing prices: search the web + and/or fetch a historical-price page that exposes a clean date/close table. +- If a source fails or is rate-limited, try a different one. Do not give up + after a single failure. +- Print ONLY the final CSV to stdout, wrapped exactly between these fences: + {CSV_START} + + {CSV_END} + Header must be `date,close`; one row per trading day; sorted ascending by + date; dates as YYYY-MM-DD; close as a plain number. Put no logs, debug + output, or commentary inside the fences. + +Never fabricate or interpolate prices — use only values you actually +retrieved.""" + + +PROMPT_TEMPLATE = ( + "Produce the daily closing-price CSV for {ticker} over {period_phrase}. " + "Print it to stdout between the {start} / {end} fences." +) + + +# --------------------------------------------------------------------------- +# API key + HTTP helpers +# --------------------------------------------------------------------------- +def resolve_api_key(api_key: Optional[str] = None) -> str: + """Find the Perplexity API key. + + Order: explicit argument, ``PERPLEXITY_API_KEY`` / ``PPLX_API_KEY`` env + vars, a ``.pplx_api_key`` file, or a ``KEY=value`` line in a local + ``.env`` (``PERPLEXITY_API_KEY`` or ``PPLX_API_KEY``). + """ + if api_key: + return api_key + for var in ("PERPLEXITY_API_KEY", "PPLX_API_KEY"): + if os.environ.get(var): + return os.environ[var] + for candidate in (".pplx_api_key", "pplx_api_key"): + path = Path(candidate) + if path.exists(): + return path.read_text().strip() + env_file = Path(".env") + if env_file.exists(): + for line in env_file.read_text().splitlines(): + line = line.strip() + if "=" not in line: + continue + name, value = line.split("=", 1) + if name.strip() in ("PERPLEXITY_API_KEY", "PPLX_API_KEY"): + return value.strip().strip('"').strip("'") + raise RuntimeError( + "API key not found. Set PERPLEXITY_API_KEY, pass --api-key, or add it " + "to a .pplx_api_key / .env file." + ) + + +def _request( + method: str, url: str, key: str, body: Optional[dict], timeout: int +) -> Tuple[int, dict]: + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + url, + data=data, + headers={ + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + "User-Agent": "api-cookbook-finance-chart-sandbox/1.0", + }, + method=method, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as err: + try: + return err.code, json.loads(err.read().decode()) + except Exception: # noqa: BLE001 + return err.code, {"error": {"message": err.reason}} + + +def _poll(base_url: str, key: str, response_id: str, deadline: float) -> dict: + """Poll a background response until terminal status (resilient to 5xx).""" + url = f"{base_url}{RESPONSES_PATH}/{response_id}" + body: dict = {"status": "in_progress"} + while body.get("status") in ("queued", "in_progress"): + if time.time() > deadline: + raise TimeoutError("Timed out waiting for the sandbox response.") + time.sleep(3) + status, body = _request("GET", url, key, None, timeout=60) + if status >= 500: + # Transient server error mid-poll — keep waiting. + body = {"status": "in_progress"} + return body + + +def run_sandbox_request( + base_url: str, + key: str, + ticker: str, + period_phrase: str, + model: str, + max_steps: int, + poll_timeout: int, +) -> dict: + """Submit one background Agent API call and poll it to completion.""" + payload = { + "model": model, + "instructions": SYSTEM_PROMPT, + "input": PROMPT_TEMPLATE.format( + ticker=ticker.upper(), + period_phrase=period_phrase, + start=CSV_START, + end=CSV_END, + ), + "tools": [{"type": "sandbox"}], + "background": True, + "max_output_tokens": 4096, + "max_steps": max_steps, + } + status, body = _request( + "POST", f"{base_url}{RESPONSES_PATH}", key, payload, timeout=120 + ) + if status >= 400: + raise RuntimeError(f"Agent API error {status}: {body.get('error', body)}") + if not body.get("id"): + raise RuntimeError(f"Unexpected response (no id): {body}") + return _poll(base_url, key, body["id"], deadline=time.time() + poll_timeout) + + +# --------------------------------------------------------------------------- +# Response parsing +# --------------------------------------------------------------------------- +def _sandbox_stdout(response: dict) -> str: + """Concatenate stdout from every sandbox execution result.""" + chunks: List[str] = [] + for item in response.get("output", []) or []: + if item.get("type") != "sandbox_results": + continue + # Real shape nests executions under `results`; tolerate a flat shape. + results = item.get("results") + if results: + for res in results: + if res.get("stdout"): + chunks.append(res["stdout"]) + elif item.get("stdout"): + chunks.append(item["stdout"]) + return "\n".join(chunks) + + +def _message_text(response: dict) -> str: + """Concatenate assistant ``output_text`` blocks.""" + chunks: List[str] = [] + for item in response.get("output", []) or []: + if item.get("type") != "message": + continue + for block in item.get("content", []) or []: + if block.get("type") == "output_text" and block.get("text"): + chunks.append(block["text"]) + return "\n".join(chunks) + + +def extract_csv(response: dict) -> Optional[str]: + """Find the fenced CSV in the sandbox stdout, then the message text. + + Returns the CSV body (without fences), or None if nothing usable is found. + """ + fence = re.compile( + re.escape(CSV_START) + r"\s*(.*?)\s*" + re.escape(CSV_END), re.S + ) + for haystack in (_sandbox_stdout(response), _message_text(response)): + match = fence.search(haystack) + if match and match.group(1).strip(): + return match.group(1).strip() + # Fallback: a fenced ```csv block in the message. + block = re.search(r"```csv\s*(.*?)```", _message_text(response), re.S) + if block: + lines = block.group(1).strip().splitlines() + if lines and "date" in lines[0].lower(): + return block.group(1).strip() + return None + + +def parse_csv(csv_text: str) -> Tuple[List[datetime], List[float]]: + """Parse `date,close` CSV text into parallel lists, sorted by date.""" + reader = csv.DictReader(csv_text.splitlines()) + if not reader.fieldnames: + raise RuntimeError("Empty CSV.") + cols = {name.strip().lower(): name for name in reader.fieldnames} + if "date" not in cols or "close" not in cols: + raise RuntimeError( + f"CSV missing date/close columns; got {reader.fieldnames}." + ) + + rows: List[Tuple[datetime, float]] = [] + for row in reader: + raw_date = (row.get(cols["date"]) or "").strip() + raw_close = (row.get(cols["close"]) or "").strip() + if not raw_date or not raw_close: + continue + try: + day = datetime.strptime(raw_date[:10], "%Y-%m-%d") + close = float(raw_close.replace(",", "").replace("$", "")) + except ValueError: + continue + rows.append((day, close)) + + if len(rows) < 2: + raise RuntimeError("Fewer than 2 valid date,close rows parsed.") + rows.sort(key=lambda r: r[0]) + return [r[0] for r in rows], [r[1] for r in rows] + + +def render_chart( + dates: List[datetime], + closes: List[float], + ticker: str, + period_label: str, + png_path: Path, +) -> None: + """Render a closing-price line chart to ``png_path``.""" + import matplotlib + + matplotlib.use("Agg") # headless: no display needed + import matplotlib.pyplot as plt + from matplotlib.dates import AutoDateLocator, ConciseDateFormatter + + fig, ax = plt.subplots(figsize=(10, 5)) + ax.plot(dates, closes, color="#1f77b4", linewidth=1.6) + ax.fill_between(dates, closes, min(closes), color="#1f77b4", alpha=0.08) + ax.set_title(f"{ticker.upper()} closing price — {period_label}") + ax.set_xlabel("Date") + ax.set_ylabel("Close (USD)") + ax.grid(True, linestyle="--", alpha=0.4) + + locator = AutoDateLocator() + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(ConciseDateFormatter(locator)) + + fig.tight_layout() + fig.savefig(png_path, dpi=150) + plt.close(fig) + + +def sandbox_invocations(response: dict) -> int: + details = (response.get("usage") or {}).get("tool_calls_details") or {} + return (details.get("sandbox") or {}).get("invocation", 0) or 0 + + +def total_cost(response: dict) -> Optional[Tuple[float, str]]: + cost = (response.get("usage") or {}).get("cost") + if not isinstance(cost, dict) or cost.get("total_cost") is None: + return None + return float(cost["total_cost"]), cost.get("currency", "USD") + + +def build_period_phrase( + period: Optional[str], start: Optional[str], end: Optional[str] +) -> Tuple[str, str]: + """Return (period_label, natural-language phrase) for prompts/filenames.""" + if start or end: + label = f"{start}..{end}" + if start and end: + phrase = f"the period from {start} to {end}" + elif start: + phrase = f"the period since {start}" + else: + phrase = f"the period up to {end}" + return label, phrase + period = period or "6mo" + return period, PERIOD_PHRASES.get(period, f"the past {period}") + + +def fetch_price_series( + base_url: str, + key: str, + ticker: str, + period_phrase: str, + model: str, + attempts: int, + max_steps: int, + poll_timeout: int, + on_attempt=None, +) -> Tuple[List[datetime], List[float], str, dict]: + """Run up to ``attempts`` background sandbox calls until a usable CSV parses. + + Returns ``(dates, closes, csv_text, response)``. Raises ``RuntimeError`` if + no attempt yields a parseable ``date,close`` CSV. ``on_attempt(n, total, + note)`` is an optional progress callback (``note`` is None at the start of + an attempt, or a short failure reason). + """ + response: dict = {} + for attempt in range(1, attempts + 1): + if on_attempt: + on_attempt(attempt, attempts, None) + try: + response = run_sandbox_request( + base_url, key, ticker, period_phrase, model, max_steps, poll_timeout + ) + except (RuntimeError, TimeoutError) as err: + if on_attempt: + on_attempt(attempt, attempts, str(err)) + continue + + if response.get("status") == "failed": + if on_attempt: + on_attempt(attempt, attempts, f"request failed: {response.get('error')}") + continue + + candidate = extract_csv(response) + if not candidate: + if on_attempt: + on_attempt(attempt, attempts, "no fenced CSV in output") + continue + try: + dates, closes = parse_csv(candidate) + except RuntimeError as err: + if on_attempt: + on_attempt(attempt, attempts, f"unusable CSV: {err}") + continue + return dates, closes, candidate, response + + raise RuntimeError( + f"Could not obtain a usable price CSV from the sandbox after " + f"{attempts} attempt(s). Sandbox data fetching is best-effort " + "(third-party sources rate-limit); try more attempts or rerun." + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Plot a stock's closing-price history using the Perplexity Agent " + "API sandbox tool (background task), rendered locally." + ) + ) + parser.add_argument("ticker", help="Ticker symbol, e.g. AAPL, MSFT, NVDA.") + parser.add_argument( + "--period", + default="6mo", + help="Lookback window: 1mo, 3mo, 6mo (default), 1y, 2y, 5y, ...", + ) + parser.add_argument("--start", help="Start date YYYY-MM-DD (overrides --period).") + parser.add_argument("--end", help="End date YYYY-MM-DD (use with --start).") + parser.add_argument("--model", default="openai/gpt-5.5", help="Agent API model.") + parser.add_argument("--out-dir", default=".", help="Directory for the CSV and PNG.") + parser.add_argument( + "--attempts", + type=int, + default=3, + help="Max background calls to try until a usable CSV comes back " + "(each is a separate sandbox session). Default 3.", + ) + parser.add_argument( + "--max-steps", type=int, default=25, help="Agent max_steps per attempt." + ) + parser.add_argument( + "--poll-timeout", + type=int, + default=300, + help="Seconds to poll each background call before giving up.", + ) + parser.add_argument( + "--base-url", + default=os.environ.get("PERPLEXITY_BASE_URL", DEFAULT_BASE_URL), + help="Agent API base URL (or set PERPLEXITY_BASE_URL).", + ) + parser.add_argument("--api-key", help="Perplexity API key.") + parser.add_argument( + "--keep-json", action="store_true", help="Write the raw response JSON." + ) + args = parser.parse_args() + + try: + key = resolve_api_key(args.api_key) + except RuntimeError as err: + print(f"Error: {err}", file=sys.stderr) + return 1 + + ticker = args.ticker.upper() + period_label, period_phrase = build_period_phrase( + args.period, args.start, args.end + ) + + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + slug = f"{ticker}_{period_label}".replace("..", "_to_") + csv_path = out_dir / f"{slug}.csv" + png_path = out_dir / f"{slug}.png" + + def _log(attempt: int, total: int, note: Optional[str]) -> None: + if note is None: + print( + f"[attempt {attempt}/{total}] Asking the sandbox to fetch " + f"{ticker} closing prices over {period_phrase}...", + file=sys.stderr, + ) + else: + print(f" {note}", file=sys.stderr) + + try: + dates, closes, csv_text, response = fetch_price_series( + args.base_url, key, ticker, period_phrase, args.model, + args.attempts, args.max_steps, args.poll_timeout, on_attempt=_log, + ) + except RuntimeError as err: + print(f"Error: {err}", file=sys.stderr) + return 3 + + if args.keep_json and response: + (out_dir / f"{slug}.json").write_text(json.dumps(response, indent=2)) + + csv_path.write_text(csv_text + "\n") + render_chart(dates, closes, ticker, period_label, png_path) + + print(f"\nData points: {len(dates)} " + f"({dates[0]:%Y-%m-%d} → {dates[-1]:%Y-%m-%d})") + print(f"CSV: {csv_path}") + print(f"Chart: {png_path}") + print(f"Sandbox invocations: {sandbox_invocations(response)}") + cost = total_cost(response) + if cost is not None: + print(f"Cost: {cost[0]:.4f} {cost[1]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/examples/finance-chart-sandbox/requirements.txt b/docs/examples/finance-chart-sandbox/requirements.txt new file mode 100644 index 0000000..632e087 --- /dev/null +++ b/docs/examples/finance-chart-sandbox/requirements.txt @@ -0,0 +1,2 @@ +# The Agent API is called over raw HTTP (stdlib urllib) — no SDK needed. +matplotlib>=3.7 diff --git a/docs/examples/finance-chart-sandbox/webapp/app.py b/docs/examples/finance-chart-sandbox/webapp/app.py new file mode 100644 index 0000000..0f3fd53 --- /dev/null +++ b/docs/examples/finance-chart-sandbox/webapp/app.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +FastAPI backend for the Finance Chart (Sandbox) UI. + +The user types a natural-language question ("apple stock price over the last 6 +months?"). The backend, using the Perplexity Python SDK, runs a two-phase flow: + + Phase 1 (data) A *background* Agent API request gives the model the + ``sandbox`` tool, which resolves the ticker + period from + the question, fetches the daily closing prices inside an + isolated container, and prints a META + ``date,close`` CSV. + Phase 2 (answer) A *streaming* request (no sandbox) writes a short + natural-language analysis of that series, token by token. + +The sandbox tool only runs as a background task (not streamable), so the prose +answer is produced by the separate streaming call in phase 2. + +Endpoints: + POST /api/charts -> submit a question, returns {"job_id"} + GET /api/charts/{id}/events -> Server-Sent Events: progress, chart, + streamed answer tokens, done/error + GET /api/charts/{id}/response.json -> the raw Agent API response (phase 1) + GET /api/charts/{id}/csv -> download the date,close CSV + GET /api/charts/{id} -> plain JSON status snapshot + +Execution runs in a worker thread and writes incremental state onto the job, so +the SSE stream merely *tails* that state — reconnects never re-run the work. + +CSV-extraction/parsing helpers are reused from the CLI module +(``finance_chart_sandbox``); only the API call differs (SDK here, raw HTTP there). +""" + +import json +import os +import re +import sys +import threading +import time +import uuid +from pathlib import Path +from typing import Dict, List, Optional + +from fastapi import FastAPI, HTTPException +from fastapi.responses import PlainTextResponse, Response, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from perplexity import APIError, Perplexity + +# Reuse the CLI module's tool-agnostic helpers (CSV extraction, parsing, usage). +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import finance_chart_sandbox as fcs # noqa: E402 + +app = FastAPI(title="Finance Chart (Sandbox) UI") + +# In-memory job store. Fine for a single-process demo; swap for Redis/DB to +# scale out. Guarded by a lock because a worker thread mutates it. +JOBS: Dict[str, dict] = {} +_LOCK = threading.Lock() + +DEFAULT_MODEL = "openai/gpt-5.5" +META_START = "===META_START===" +META_END = "===META_END===" + +# Phase 1: data-gathering inside the sandbox. +DATA_PROMPT = f"""You answer natural-language questions about a stock's recent +price history by producing a chart-ready CSV. + +You have the `sandbox` tool — an isolated Python environment that includes the +`perplexity` SDK (web search and URL fetch) plus pandas and the standard +library. + +Do this: +1. Read the user's question and determine the stock TICKER (resolve a company + name to its symbol, e.g. "apple" -> AAPL) and the time PERIOD they asked + about (default to the last 6 months if none is given). +2. Use the sandbox to obtain the DAILY closing prices for that ticker over that + period: search the web and/or fetch a historical-price page with a clean + date/close table. If a source fails or is rate-limited, try another. Never + fabricate or interpolate prices — use only values you actually retrieved. +3. Print to stdout, in exactly this order and nothing else: + {META_START} + ticker: + label: + {META_END} + {fcs.CSV_START} + date,close + + {fcs.CSV_END}""" + +# Phase 2: the streamed natural-language analysis. +ANSWER_PROMPT = """You are a concise financial analyst. Given a user's question +and a stock's daily closing prices, answer in 3-5 sentences: the overall trend, +the start and end levels, notable highs/lows or moves, and the net change over +the period. Use only the data provided. Do not give investment advice. Write +plain prose — no markdown, asterisks, headings, or bullet points.""" + + +class ChartRequest(BaseModel): + query: str = Field(..., min_length=3, max_length=400) + attempts: int = Field(3, ge=1, le=8) + max_steps: int = Field(25, ge=5, le=60) + + +# --------------------------------------------------------------------------- +# Job state helpers (all mutate under the lock) +# --------------------------------------------------------------------------- +def _set(job_id: str, **fields) -> None: + with _LOCK: + JOBS[job_id].update(fields) + + +def _push(job_id: str, event: dict) -> None: + with _LOCK: + JOBS[job_id]["events"].append(event) + + +def _append_answer(job_id: str, text: str) -> None: + with _LOCK: + JOBS[job_id]["answer"] += text + + +def _to_dict(response) -> dict: + """SDK response object -> plain dict (silence harmless serializer warnings).""" + try: + return response.model_dump(warnings=False) + except TypeError: # older pydantic + return response.model_dump() + + +# --------------------------------------------------------------------------- +# SDK calls +# --------------------------------------------------------------------------- +def _make_client() -> Perplexity: + kw = {} + if os.environ.get("PERPLEXITY_BASE_URL"): + kw["base_url"] = os.environ["PERPLEXITY_BASE_URL"] + return Perplexity(api_key=fcs.resolve_api_key(), **kw) + + +def _run_sandbox(client, query, model, max_steps, poll_timeout) -> dict: + """Phase 1: one background SDK call (create + poll by id), returned as a dict.""" + response = client.responses.create( + model=model, + instructions=DATA_PROMPT, + input=query, + tools=[{"type": "sandbox"}], + background=True, + max_output_tokens=4096, + max_steps=max_steps, + ) + deadline = time.time() + poll_timeout + while response.status in ("queued", "in_progress"): + if time.time() > deadline: + raise TimeoutError("Timed out waiting for the sandbox response.") + time.sleep(3) + response = client.responses.retrieve(response.id) + return _to_dict(response) + + +def _parse_meta(response: dict) -> Dict[str, str]: + haystack = fcs._sandbox_stdout(response) + "\n" + fcs._message_text(response) + block = re.search( + re.escape(META_START) + r"\s*(.*?)\s*" + re.escape(META_END), haystack, re.S + ) + meta: Dict[str, str] = {} + if block: + for line in block.group(1).splitlines(): + if ":" in line: + key, val = line.split(":", 1) + meta[key.strip().lower()] = val.strip() + return meta + + +def _fetch_data(client, query, attempts, max_steps, poll_timeout, on_attempt): + """Retry phase 1 until a usable CSV parses.""" + for attempt in range(1, attempts + 1): + on_attempt(attempt, attempts, None) + try: + response = _run_sandbox(client, query, DEFAULT_MODEL, max_steps, poll_timeout) + except (APIError, TimeoutError) as err: + on_attempt(attempt, attempts, str(err)) + continue + if response.get("status") == "failed": + on_attempt(attempt, attempts, f"request failed: {response.get('error')}") + continue + candidate = fcs.extract_csv(response) + if not candidate: + on_attempt(attempt, attempts, "no fenced CSV in output") + continue + try: + dates, closes = fcs.parse_csv(candidate) + except RuntimeError as err: + on_attempt(attempt, attempts, f"unusable CSV: {err}") + continue + return dates, closes, candidate, _parse_meta(response), response + raise RuntimeError( + f"Could not answer the query from the sandbox after {attempts} " + "attempt(s). Data fetching is best-effort (sources rate-limit); " + "try rephrasing or asking again." + ) + + +def _stream_answer(client, query, ticker, label, dates, closes, on_text) -> None: + """Phase 2: stream a short analysis of the series, token by token.""" + rows = list(zip(dates, closes)) + if len(rows) > 250: # keep the prompt small for long ranges + step = len(rows) // 250 + 1 + rows = rows[::step] + [rows[-1]] + series = "\n".join(f"{d:%Y-%m-%d},{c}" for d, c in rows) + user = ( + f"Question: {query}\n" + f"Ticker: {ticker or 'unknown'} ({label})\n" + f"Daily closing prices (date,close):\n{series}\n\nWrite the analysis." + ) + stream = client.responses.create( + model=DEFAULT_MODEL, + instructions=ANSWER_PROMPT, + input=user, + stream=True, + max_output_tokens=400, + ) + for event in stream: + if getattr(event, "type", None) == "response.output_text.delta": + delta = getattr(event, "delta", None) + if delta: + on_text(delta) + + +# --------------------------------------------------------------------------- +# Worker +# --------------------------------------------------------------------------- +def _run_job(job_id: str) -> None: + with _LOCK: + job = dict(JOBS[job_id]) + query, attempts, max_steps = job["query"], job["attempts"], job["max_steps"] + + try: + client = _make_client() + except RuntimeError as err: + _push(job_id, {"kind": "progress", "message": str(err)}) + _set(job_id, status="error", error=str(err)) + return + + def on_attempt(n: int, total: int, note: Optional[str]) -> None: + msg = (f"Attempt {n}/{total}: the sandbox is fetching prices…" + if note is None else f"Attempt {n}/{total}: {note}") + _set(job_id, status="running") + _push(job_id, {"kind": "progress", "message": msg}) + + try: + dates, closes, csv_text, meta, response = _fetch_data( + client, query, attempts, max_steps, poll_timeout=300, on_attempt=on_attempt + ) + except (RuntimeError, TimeoutError) as err: + _set(job_id, status="error", error=str(err)) + return + + ticker = meta.get("ticker", "").upper() or None + label = meta.get("label") or "price history" + title = f"{ticker} · closing price · {label}" if ticker else query + cost = fcs.total_cost(response) + result = { + "title": title, + "ticker": ticker, + "label": label, + "points": len(dates), + "first_date": dates[0].strftime("%Y-%m-%d"), + "last_date": dates[-1].strftime("%Y-%m-%d"), + "dates": [d.strftime("%Y-%m-%d") for d in dates], + "closes": closes, + "sandbox_invocations": fcs.sandbox_invocations(response), + "cost": ({"total": cost[0], "currency": cost[1]} if cost else None), + } + filename = ((ticker or "chart") + "_" + re.sub(r"[^\w]+", "-", label)).strip("-") + _set(job_id, csv=csv_text, filename=filename, response=response, result=result) + _push(job_id, {"kind": "chart", "result": result}) + + # Phase 2: stream the natural-language analysis. + _push(job_id, {"kind": "answer_start"}) + try: + _stream_answer(client, query, ticker, label, dates, closes, + on_text=lambda t: _append_answer(job_id, t)) + except APIError as err: + _append_answer(job_id, f"\n\n_(analysis unavailable: {err})_") + + _set(job_id, status="done") + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- +def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data)}\n\n" + + +@app.post("/api/charts") +def create_chart(req: ChartRequest) -> dict: + job_id = uuid.uuid4().hex + with _LOCK: + JOBS[job_id] = { + "status": "queued", "query": req.query.strip(), + "attempts": req.attempts, "max_steps": req.max_steps, + "events": [], "answer": "", "result": None, "csv": None, + "filename": "chart", "response": None, "error": None, + } + threading.Thread(target=_run_job, args=(job_id,), daemon=True).start() + return {"job_id": job_id, "status": "queued"} + + +@app.get("/api/charts/{job_id}/events") +def stream_events(job_id: str) -> StreamingResponse: + with _LOCK: + if job_id not in JOBS: + raise HTTPException(status_code=404, detail="Unknown job_id") + + def gen(): + ev_idx, ans_pos = 0, 0 + yield ":ok\n\n" # open the stream + while True: + with _LOCK: + job = JOBS.get(job_id) + new_events = job["events"][ev_idx:] + ev_idx = len(job["events"]) + ans_delta = job["answer"][ans_pos:] + ans_pos = len(job["answer"]) + status, error = job["status"], job["error"] + has_json = job["response"] is not None + for e in new_events: + if e["kind"] == "progress": + yield _sse("progress", {"message": e["message"]}) + elif e["kind"] == "chart": + yield _sse("chart", e["result"]) + elif e["kind"] == "answer_start": + yield _sse("answer_start", {}) + if ans_delta: + yield _sse("token", {"text": ans_delta}) + if status == "error": + yield _sse("error", {"message": error or "Run failed."}) + return + if status == "done": + yield _sse("done", {"has_json": has_json}) + return + time.sleep(0.12) + + return StreamingResponse( + gen(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.get("/api/charts/{job_id}") +def get_status(job_id: str) -> dict: + with _LOCK: + job = JOBS.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Unknown job_id") + return {"status": job["status"], "result": job["result"], + "answer": job["answer"], "error": job["error"], + "has_json": job["response"] is not None} + + +@app.get("/api/charts/{job_id}/response.json") +def get_response_json(job_id: str) -> Response: + with _LOCK: + job = JOBS.get(job_id) + response = job["response"] if job else None + if response is None: + raise HTTPException(status_code=404, detail="No response stored yet") + return Response(json.dumps(response, indent=2), media_type="application/json") + + +@app.get("/api/charts/{job_id}/csv") +def get_csv(job_id: str) -> PlainTextResponse: + with _LOCK: + job = JOBS.get(job_id) + if job is None or not job.get("csv"): + raise HTTPException(status_code=404, detail="No CSV for this job") + return PlainTextResponse( + job["csv"], + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{job["filename"]}.csv"'}, + ) + + +# Serve the static frontend at the root. Mounted last so /api/* takes priority. +_STATIC = Path(__file__).resolve().parent / "static" +app.mount("/", StaticFiles(directory=str(_STATIC), html=True), name="static") + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "8000"))) diff --git a/docs/examples/finance-chart-sandbox/webapp/requirements.txt b/docs/examples/finance-chart-sandbox/webapp/requirements.txt new file mode 100644 index 0000000..15fdfe0 --- /dev/null +++ b/docs/examples/finance-chart-sandbox/webapp/requirements.txt @@ -0,0 +1,3 @@ +perplexityai>=0.35.1 +fastapi>=0.110 +uvicorn>=0.29 diff --git a/docs/examples/finance-chart-sandbox/webapp/static/app.js b/docs/examples/finance-chart-sandbox/webapp/static/app.js new file mode 100644 index 0000000..60e7179 --- /dev/null +++ b/docs/examples/finance-chart-sandbox/webapp/static/app.js @@ -0,0 +1,160 @@ +"use strict"; + +const form = document.getElementById("chart-form"); +const queryInput = document.getElementById("query"); +const submitBtn = document.getElementById("submit"); +const statusEl = document.getElementById("status"); +const statusText = document.getElementById("status-text"); +const errorEl = document.getElementById("error"); +const resultEl = document.getElementById("result"); +const resultTitle = document.getElementById("result-title"); +const statsEl = document.getElementById("stats"); +const downloadBtn = document.getElementById("download"); +const viewJsonLink = document.getElementById("view-json"); +const answerWrap = document.getElementById("answer-wrap"); +const answerEl = document.getElementById("answer"); + +let chart = null; +let es = null; + +function show(el) { el.classList.remove("hidden"); } +function hide(el) { el.classList.add("hidden"); } + +function setBusy(busy) { + submitBtn.disabled = busy; + submitBtn.textContent = busy ? "Working…" : "Ask"; +} + +// Example chips fill the query box. +document.querySelectorAll(".chip").forEach((chip) => { + chip.addEventListener("click", () => { + queryInput.value = chip.textContent; + queryInput.focus(); + }); +}); + +function renderChart(r) { + hide(statusEl); + resultTitle.textContent = r.title; + + const ctx = document.getElementById("chart").getContext("2d"); + if (chart) chart.destroy(); + chart = new Chart(ctx, { + type: "line", + data: { + labels: r.dates, + datasets: [{ + label: "Close (USD)", + data: r.closes, + borderColor: "#1f77b4", + backgroundColor: "rgba(31,119,180,0.12)", + fill: true, + pointRadius: 0, + borderWidth: 1.8, + tension: 0.15, + }], + }, + options: { + responsive: true, + interaction: { mode: "index", intersect: false }, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { maxTicksLimit: 10, color: "#9aa0ad" }, grid: { color: "#262a36" } }, + y: { ticks: { color: "#9aa0ad" }, grid: { color: "#262a36" } }, + }, + }, + }); + + const cost = r.cost ? `${r.cost.total.toFixed(4)} ${r.cost.currency}` : "—"; + statsEl.innerHTML = ""; + for (const [k, v] of [ + ["Ticker", r.ticker || "—"], + ["Data points", r.points], + ["Range", `${r.first_date} → ${r.last_date}`], + ["Sandbox calls", r.sandbox_invocations], + ["Cost", cost], + ]) { + const div = document.createElement("div"); + div.innerHTML = `
${k}
${v}
`; + statsEl.appendChild(div); + } + show(resultEl); +} + +function onError(message) { + if (es) { es.close(); es = null; } + hide(statusEl); + setBusy(false); + errorEl.textContent = message; + show(errorEl); +} + +function startStream(jobId) { + viewJsonLink.href = `/api/charts/${jobId}/response.json`; + downloadBtn.onclick = () => { window.location.href = `/api/charts/${jobId}/csv`; }; + + es = new EventSource(`/api/charts/${jobId}/events`); + + es.addEventListener("progress", (e) => { + statusText.textContent = JSON.parse(e.data).message; + }); + + es.addEventListener("chart", (e) => { + renderChart(JSON.parse(e.data)); + }); + + es.addEventListener("answer_start", () => { + answerEl.textContent = ""; + answerEl.classList.add("streaming"); + show(answerWrap); + }); + + es.addEventListener("token", (e) => { + answerEl.textContent += JSON.parse(e.data).text; + }); + + es.addEventListener("done", () => { + answerEl.classList.remove("streaming"); + setBusy(false); + es.close(); es = null; + }); + + es.addEventListener("error", (e) => { + let msg = "The stream failed."; + try { msg = JSON.parse(e.data).message || msg; } catch (_) {} + onError(msg); + }); + + // Network-level failure (not a server "error" event). + es.onerror = () => { + if (es && es.readyState === EventSource.CLOSED) { + onError("Connection to the server was lost."); + } + }; +} + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + hide(errorEl); + hide(resultEl); + hide(answerWrap); + setBusy(true); + show(statusEl); + statusText.textContent = "Submitting…"; + + try { + const res = await fetch("/api/charts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: queryInput.value.trim() }), + }); + if (!res.ok) { + const detail = await res.json().catch(() => ({})); + throw new Error(detail.detail || `Submit failed (HTTP ${res.status})`); + } + const { job_id } = await res.json(); + startStream(job_id); + } catch (err) { + onError(err.message || String(err)); + } +}); diff --git a/docs/examples/finance-chart-sandbox/webapp/static/index.html b/docs/examples/finance-chart-sandbox/webapp/static/index.html new file mode 100644 index 0000000..c7a9cef --- /dev/null +++ b/docs/examples/finance-chart-sandbox/webapp/static/index.html @@ -0,0 +1,66 @@ + + + + + + Stock Price Q&A · Perplexity Agent API sandbox + + + + +
+
+

Stock Price Q&A

+

+ Ask in plain English. The Perplexity Agent API sandbox + tool figures out the ticker and period, fetches the price history + inside an isolated container, and we chart it here. +

+
+ +
+ + +
+ Try: + + + +
+

+ Each question starts a background sandbox session (~30–60s) and may + retry if a data source rate-limits. +

+
+ + + + + + +
+ + + + diff --git a/docs/examples/finance-chart-sandbox/webapp/static/styles.css b/docs/examples/finance-chart-sandbox/webapp/static/styles.css new file mode 100644 index 0000000..5adbc66 --- /dev/null +++ b/docs/examples/finance-chart-sandbox/webapp/static/styles.css @@ -0,0 +1,182 @@ +:root { + --bg: #0f1117; + --card: #181b24; + --border: #262a36; + --text: #e6e8ee; + --muted: #9aa0ad; + --accent: #20a5a0; + --accent-2: #1f77b4; + --error: #ff6b6b; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, + Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +.container { + max-width: 860px; + margin: 0 auto; + padding: 2.5rem 1.25rem 4rem; +} + +header h1 { margin: 0 0 0.25rem; font-size: 1.7rem; } +.sub { color: var(--muted); margin: 0 0 1.5rem; } +code { background: #11141b; padding: 0.1em 0.35em; border-radius: 4px; } + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.25rem; +} + +.row { + display: flex; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.85rem; + color: var(--muted); + flex: 1 1 140px; +} + +input, select { + background: #11141b; + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 0.55rem 0.65rem; + font-size: 1rem; +} +input:focus, select:focus { outline: 2px solid var(--accent); } + +button { + background: var(--accent); + color: #04201f; + font-weight: 600; + border: none; + border-radius: 8px; + padding: 0.6rem 1.1rem; + font-size: 1rem; + cursor: pointer; +} +button:hover { filter: brightness(1.07); } +button:disabled { opacity: 0.55; cursor: not-allowed; } +button.secondary { + background: transparent; + color: var(--accent); + border: 1px solid var(--accent); +} + +.query-label { + flex: 1 1 100%; + margin-bottom: 0.9rem; +} +.query-label input { font-size: 1.05rem; padding: 0.7rem 0.8rem; } + +#submit { width: 100%; padding: 0.7rem 1.1rem; } + +.examples { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 0.9rem; +} +.examples > span { color: var(--muted); font-size: 0.8rem; } +.chip { + background: #11141b; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.3rem 0.7rem; + font-size: 0.8rem; + font-weight: 400; + cursor: pointer; +} +.chip:hover { color: var(--text); border-color: var(--accent); filter: none; } + +.hint { color: var(--muted); font-size: 0.8rem; margin: 0.9rem 0 0; } + +.status { + display: flex; + align-items: center; + gap: 0.6rem; + margin-top: 1.25rem; + color: var(--muted); +} +.spinner { + width: 16px; height: 16px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.error { + margin-top: 1.25rem; + padding: 0.9rem 1rem; + border: 1px solid var(--error); + border-radius: 8px; + color: var(--error); + background: rgba(255, 107, 107, 0.08); +} + +.result { margin-top: 1.5rem; } +.result-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.5rem; +} +.result-head h2 { margin: 0; font-size: 1.15rem; } +.actions { display: flex; gap: 0.5rem; flex-shrink: 0; } +a.secondary { + display: inline-block; + text-decoration: none; + text-align: center; + line-height: 1.4; +} + +.answer-wrap { margin-top: 1.25rem; } +.answer-wrap h3 { + margin: 0 0 0.4rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} +.answer { margin: 0; color: var(--text); white-space: pre-wrap; } +.answer.streaming::after { + content: "▍"; + color: var(--accent); + animation: blink 1s step-end infinite; +} +@keyframes blink { 50% { opacity: 0; } } + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 0.75rem 1.5rem; + margin: 1.25rem 0 0; +} +.stats div { display: flex; flex-direction: column; } +.stats dt { color: var(--muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; } +.stats dd { margin: 0.15rem 0 0; font-size: 1.05rem; font-variant-numeric: tabular-nums; } + +.hidden { display: none !important; } diff --git a/static/img/finance-chart-sandbox-aapl.png b/static/img/finance-chart-sandbox-aapl.png new file mode 100644 index 0000000..cc146af Binary files /dev/null and b/static/img/finance-chart-sandbox-aapl.png differ diff --git a/static/img/finance-chart-sandbox-ui.png b/static/img/finance-chart-sandbox-ui.png new file mode 100644 index 0000000..3dfd3d4 Binary files /dev/null and b/static/img/finance-chart-sandbox-ui.png differ