From 118ae2660a265df3df45b618e82e4a32ed85d200 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 9 Apr 2026 20:04:45 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Imitate=20tab=20=E2=80=94=20pull=20CF?= =?UTF-8?q?=20product=20samples,=20compare=20LLM=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (app/imitate.py): - GET /api/imitate/products — reads imitate: config, checks online status - GET /api/imitate/products/{id}/sample — fetches real item from product API - GET /api/imitate/run (SSE) — streams ollama responses for selected models - POST /api/imitate/push-corrections — queues results in SFT corrections JSONL Frontend (ImitateView.vue): - Step 1: product picker grid (online/offline status, icon from config) - Step 2: raw sample preview + editable prompt textarea - Step 3: ollama model multi-select, temperature slider, SSE run with live log - Step 4: response cards side by side, push to Corrections button Wiring: - app/api.py: include imitate_router at /api/imitate - web/src/router: /imitate route + lazy import - AppSidebar: Imitate nav entry (mirror icon) - config/label_tool.yaml.example: imitate: section with peregrine example - 16 unit tests (100% passing) Also: BenchmarkView.vue Compare panel — side-by-side run diff for bench results --- app/api.py | 3 + app/cforch.py | 8 +- app/imitate.py | 351 ++++++++++++ config/label_tool.yaml.example | 43 ++ tests/test_imitate.py | 242 ++++++++ web/src/components/AppSidebar.vue | 1 + web/src/router/index.ts | 2 + web/src/views/BenchmarkView.vue | 321 ++++++++++- web/src/views/ImitateView.vue | 898 ++++++++++++++++++++++++++++++ 9 files changed, 1865 insertions(+), 4 deletions(-) create mode 100644 app/imitate.py create mode 100644 tests/test_imitate.py create mode 100644 web/src/views/ImitateView.vue diff --git a/app/api.py b/app/api.py index e1babf5..f562e20 100644 --- a/app/api.py +++ b/app/api.py @@ -152,6 +152,9 @@ app.include_router(models_router, prefix="/api/models") from app.cforch import router as cforch_router app.include_router(cforch_router, prefix="/api/cforch") +from app.imitate import router as imitate_router +app.include_router(imitate_router, prefix="/api/imitate") + # In-memory last-action store (single user, local tool — in-memory is fine) _last_action: dict | None = None diff --git a/app/cforch.py b/app/cforch.py index 27ca050..a4a35c6 100644 --- a/app/cforch.py +++ b/app/cforch.py @@ -114,9 +114,11 @@ def get_tasks() -> dict: if not isinstance(t, dict): continue tasks.append({ - "id": t.get("id", ""), - "name": t.get("name", ""), - "type": t.get("type", ""), + "id": t.get("id", ""), + "name": t.get("name", ""), + "type": t.get("type", ""), + "prompt": (t.get("prompt") or "").strip(), + "system": (t.get("system") or "").strip(), }) task_type = t.get("type", "") if task_type and task_type not in types_set: diff --git a/app/imitate.py b/app/imitate.py new file mode 100644 index 0000000..a42915d --- /dev/null +++ b/app/imitate.py @@ -0,0 +1,351 @@ +"""Avocet — Imitate tab API. + +Fetches real samples from sibling CF product APIs, sends them through selected +local LLMs (ollama), and streams responses back to the UI. Results can be +pushed into the SFT corrections queue for human review. + +All endpoints registered on `router`. api.py includes this with prefix="/api/imitate". + +Module-level globals follow the same testability pattern as cforch.py and sft.py: +override _CONFIG_DIR and _DATA_DIR via set_config_dir() / set_data_dir() in tests. +""" +from __future__ import annotations + +import json +import logging +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.error import URLError +from urllib.request import Request, urlopen + +import yaml +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from app.utils import append_jsonl + +logger = logging.getLogger(__name__) + +_ROOT = Path(__file__).parent.parent +_CONFIG_DIR: Path | None = None +_DATA_DIR: Path = _ROOT / "data" + +router = APIRouter() + + +# ── Testability seams ────────────────────────────────────────────────────────── + +def set_config_dir(path: Path | None) -> None: + global _CONFIG_DIR + _CONFIG_DIR = path + + +def set_data_dir(path: Path) -> None: + global _DATA_DIR + _DATA_DIR = path + + +# ── Internal helpers ─────────────────────────────────────────────────────────── + +def _config_file() -> Path: + if _CONFIG_DIR is not None: + return _CONFIG_DIR / "label_tool.yaml" + return _ROOT / "config" / "label_tool.yaml" + + +def _load_imitate_config() -> dict: + """Read label_tool.yaml and return the imitate sub-dict (or {} if absent).""" + f = _config_file() + if not f.exists(): + return {} + try: + raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as exc: + logger.warning("Failed to parse imitate config %s: %s", f, exc) + return {} + return raw.get("imitate", {}) or {} + + +def _load_cforch_config() -> dict: + """Read cforch section for ollama_url fallback.""" + f = _config_file() + if not f.exists(): + return {} + try: + raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as exc: + return {} + return raw.get("cforch", {}) or {} + + +def _ollama_url(cfg: dict) -> str: + cforch = _load_cforch_config() + return cfg.get("ollama_url") or cforch.get("ollama_url") or "http://localhost:11434" + + +def _http_get_json(url: str, timeout: int = 5) -> Any: + """Fetch JSON from url; raise URLError on failure.""" + req = Request(url, headers={"Accept": "application/json"}) + with urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _is_online(base_url: str) -> bool: + """Return True if the product's /api/health endpoint responds OK.""" + try: + data = _http_get_json(f"{base_url.rstrip('/')}/api/health", timeout=2) + return bool(data) + except Exception: + return False + + +def _extract_sample( + raw: Any, text_fields: list[str], sample_index: int = 0 +) -> dict[str, Any]: + """Pull one item from a list or dict response and extract text_fields.""" + item: dict[str, Any] + if isinstance(raw, list): + if not raw: + return {} + item = raw[min(sample_index, len(raw) - 1)] + elif isinstance(raw, dict): + # may be {items: [...]} or the item itself + for key in ("items", "results", "data", "jobs", "listings", "pantry"): + if key in raw and isinstance(raw[key], list): + lst = raw[key] + item = lst[min(sample_index, len(lst) - 1)] if lst else {} + break + else: + item = raw + else: + return {} + + parts = [] + for field in text_fields: + val = item.get(field) + if val and str(val).strip(): + parts.append(f"**{field}**: {val}") + return {"item": item, "text": "\n\n".join(parts)} + + +def _candidates_file() -> Path: + return _DATA_DIR / "sft_candidates.jsonl" + + +def _sse(data: dict) -> str: + return f"data: {json.dumps(data)}\n\n" + + +def _run_ollama_streaming( + ollama_base: str, + model_id: str, + prompt: str, + temperature: float, +) -> tuple[str, int]: + """Call ollama /api/generate with stream=True; return (full_response, elapsed_ms). + + Blocks until the model finishes; yields nothing — streaming is handled by + the SSE generator in run_imitate(). + """ + url = f"{ollama_base.rstrip('/')}/api/generate" + payload = json.dumps({ + "model": model_id, + "prompt": prompt, + "stream": False, + "options": {"temperature": temperature}, + }).encode("utf-8") + req = Request(url, data=payload, method="POST", + headers={"Content-Type": "application/json"}) + t0 = time.time() + try: + with urlopen(req, timeout=120) as resp: + body = json.loads(resp.read().decode("utf-8")) + elapsed = int((time.time() - t0) * 1000) + return body.get("response", ""), elapsed + except Exception as exc: + elapsed = int((time.time() - t0) * 1000) + raise RuntimeError(str(exc)) from exc + + +# ── GET /products ────────────────────────────────────────────────────────────── + +@router.get("/products") +def get_products() -> dict: + """List configured CF products with live online status.""" + cfg = _load_imitate_config() + products_raw = cfg.get("products", []) or [] + products = [] + for p in products_raw: + if not isinstance(p, dict): + continue + base_url = p.get("base_url", "") + products.append({ + "id": p.get("id", ""), + "name": p.get("name", ""), + "icon": p.get("icon", "📦"), + "description": p.get("description", ""), + "base_url": base_url, + "online": _is_online(base_url) if base_url else False, + }) + return {"products": products} + + +# ── GET /products/{product_id}/sample ───────────────────────────────────────── + +@router.get("/products/{product_id}/sample") +def get_sample(product_id: str, index: int = 0) -> dict: + """Fetch a real sample from the given product's API.""" + cfg = _load_imitate_config() + products_raw = cfg.get("products", []) or [] + + product: dict | None = None + for p in products_raw: + if isinstance(p, dict) and p.get("id") == product_id: + product = p + break + + if product is None: + raise HTTPException(404, f"Product '{product_id}' not in config") + + base_url = product.get("base_url", "").rstrip("/") + endpoint = product.get("sample_endpoint", "") + if not base_url or not endpoint: + raise HTTPException(422, "Product missing base_url or sample_endpoint") + + url = f"{base_url}{endpoint}" + try: + raw = _http_get_json(url, timeout=5) + except URLError as exc: + raise HTTPException(503, f"Product API unreachable: {exc}") from exc + except Exception as exc: + raise HTTPException(502, f"Bad response from product API: {exc}") from exc + + text_fields = product.get("text_fields", []) or [] + extracted = _extract_sample(raw, text_fields, index) + if not extracted: + raise HTTPException(404, "No sample items returned by product API") + + prompt_template = product.get("prompt_template", "{text}") + prompt = prompt_template.replace("{text}", extracted["text"]) + + return { + "product_id": product_id, + "sample_index": index, + "text": extracted["text"], + "prompt": prompt, + "raw_item": extracted.get("item", {}), + } + + +# ── GET /run (SSE) ───────────────────────────────────────────────────────────── + +@router.get("/run") +def run_imitate( + prompt: str = "", + model_ids: str = "", # comma-separated ollama model IDs + temperature: float = 0.7, + product_id: str = "", +) -> StreamingResponse: + """Run a prompt through selected ollama models and stream results as SSE.""" + + if not prompt.strip(): + raise HTTPException(422, "prompt is required") + + ids = [m.strip() for m in model_ids.split(",") if m.strip()] + if not ids: + raise HTTPException(422, "model_ids is required") + + cfg = _load_imitate_config() + ollama_base = _ollama_url(cfg) + + def generate(): + results: list[dict] = [] + yield _sse({"type": "start", "total_models": len(ids)}) + + for model_id in ids: + yield _sse({"type": "model_start", "model": model_id}) + try: + response, elapsed_ms = _run_ollama_streaming( + ollama_base, model_id, prompt, temperature + ) + result = { + "model": model_id, + "response": response, + "elapsed_ms": elapsed_ms, + "error": None, + } + except Exception as exc: + result = { + "model": model_id, + "response": "", + "elapsed_ms": 0, + "error": str(exc), + } + results.append(result) + yield _sse({"type": "model_done", **result}) + + yield _sse({"type": "complete", "results": results}) + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +# ── POST /push-corrections ───────────────────────────────────────────────────── + +class ImitateResult(BaseModel): + model: str + response: str + elapsed_ms: int + error: str | None = None + + +class PushCorrectionsRequest(BaseModel): + product_id: str + prompt: str + results: list[ImitateResult] + + +@router.post("/push-corrections") +def push_corrections(req: PushCorrectionsRequest) -> dict: + """Append imitate results to sft_candidates.jsonl for human review.""" + if not req.prompt.strip(): + raise HTTPException(422, "prompt is required") + if not req.results: + raise HTTPException(422, "results list is empty") + + ts = datetime.now(timezone.utc).isoformat() + records = [] + for r in req.results: + if r.error or not r.response.strip(): + continue + records.append({ + "id": str(uuid.uuid4()), + "source": "imitate", + "product_id": req.product_id, + "prompt_messages": [{"role": "user", "content": req.prompt}], + "model_response": r.response, + "model_id": r.model, + "elapsed_ms": r.elapsed_ms, + "status": "pending", + "created_at": ts, + }) + + if not records: + raise HTTPException(422, "No non-error results to push") + + dest = _candidates_file() + dest.parent.mkdir(parents=True, exist_ok=True) + for record in records: + append_jsonl(dest, record) + + return {"pushed": len(records)} diff --git a/config/label_tool.yaml.example b/config/label_tool.yaml.example index 9310d21..558bae4 100644 --- a/config/label_tool.yaml.example +++ b/config/label_tool.yaml.example @@ -26,3 +26,46 @@ max_per_account: 500 # produced by circuitforge-orch's benchmark harness. sft: bench_results_dir: /path/to/circuitforge-orch/scripts/bench_results + +# Imitate tab — pull real samples from sibling CF product APIs and run them +# through local LLMs to build a corrections dataset. +# ollama_url defaults to cforch.ollama_url if omitted here. +imitate: + ollama_url: http://localhost:11434 # optional — falls back to cforch.ollama_url + + products: + - id: peregrine + name: Peregrine + icon: "🦅" + description: Job search assistant + base_url: http://localhost:8502 + sample_endpoint: /api/jobs + text_fields: [title, description] + prompt_template: "Analyze this job listing and identify key requirements:\n\n{text}" + + - id: kiwi + name: Kiwi + icon: "🥝" + description: Pantry tracker + base_url: http://localhost:8511 + sample_endpoint: /api/inventory + text_fields: [name, category, notes] + prompt_template: "Describe this pantry item and estimate how best to use it:\n\n{text}" + + - id: snipe + name: Snipe + icon: "🎯" + description: eBay trust scoring + base_url: http://localhost:8509 + sample_endpoint: /api/listings + text_fields: [title, description, seller_info] + prompt_template: "Evaluate the trustworthiness of this listing and flag any red flags:\n\n{text}" + + - id: osprey + name: Osprey + icon: "📞" + description: Gov't hold-line automation + base_url: http://localhost:8520 + sample_endpoint: /api/calls/recent + text_fields: [agency, issue, notes] + prompt_template: "Draft a concise summary of this government call record:\n\n{text}" diff --git a/tests/test_imitate.py b/tests/test_imitate.py new file mode 100644 index 0000000..c795b19 --- /dev/null +++ b/tests/test_imitate.py @@ -0,0 +1,242 @@ +"""Tests for app/imitate.py — product registry, sample extraction, corrections push.""" +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.api import app +from app import imitate as _imitate_module + + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def reset_module_globals(tmp_path): + """Reset module-level config + data dir globals after each test.""" + orig_cfg = _imitate_module._CONFIG_DIR + orig_data = _imitate_module._DATA_DIR + yield + _imitate_module._CONFIG_DIR = orig_cfg + _imitate_module._DATA_DIR = orig_data + + +@pytest.fixture() +def config_dir(tmp_path) -> Path: + _imitate_module.set_config_dir(tmp_path) + return tmp_path + + +@pytest.fixture() +def data_dir(tmp_path) -> Path: + _imitate_module.set_data_dir(tmp_path) + return tmp_path + + +@pytest.fixture() +def cfg_with_products(config_dir: Path) -> Path: + """Write a label_tool.yaml with two products.""" + (config_dir / "label_tool.yaml").write_text( + """ +imitate: + ollama_url: http://localhost:11434 + products: + - id: peregrine + name: Peregrine + icon: "🦅" + description: Job search assistant + base_url: http://peregrine.local + sample_endpoint: /api/jobs + text_fields: [title, description] + prompt_template: "Analyze: {text}" + - id: kiwi + name: Kiwi + icon: "🥝" + description: Pantry tracker + base_url: http://kiwi.local + sample_endpoint: /api/inventory + text_fields: [name, notes] + prompt_template: "Describe: {text}" +""" + ) + return config_dir + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(app, raise_server_exceptions=True) + + +# ── GET /products ────────────────────────────────────────────────────────────── + +def test_products_empty_when_no_config(config_dir, client): + """Returns empty list when label_tool.yaml has no imitate section.""" + (config_dir / "label_tool.yaml").write_text("accounts: []\n") + resp = client.get("/api/imitate/products") + assert resp.status_code == 200 + assert resp.json()["products"] == [] + + +def test_products_listed(cfg_with_products, client): + """All configured products are returned with expected fields.""" + with patch.object(_imitate_module, "_is_online", return_value=True): + resp = client.get("/api/imitate/products") + assert resp.status_code == 200 + products = resp.json()["products"] + assert len(products) == 2 + ids = {p["id"] for p in products} + assert ids == {"peregrine", "kiwi"} + peregrine = next(p for p in products if p["id"] == "peregrine") + assert peregrine["name"] == "Peregrine" + assert peregrine["icon"] == "🦅" + assert peregrine["online"] is True + + +def test_products_offline_when_unreachable(cfg_with_products, client): + """Products with unreachable base_url are marked offline.""" + with patch.object(_imitate_module, "_is_online", return_value=False): + resp = client.get("/api/imitate/products") + assert all(not p["online"] for p in resp.json()["products"]) + + +# ── GET /products/{id}/sample ───────────────────────────────────────────────── + +def test_sample_unknown_product(cfg_with_products, client): + """Returns 404 for a product id not in config.""" + resp = client.get("/api/imitate/products/nonexistent/sample") + assert resp.status_code == 404 + + +def test_sample_fetched_from_list(cfg_with_products, client): + """Extracts first item from a list API response.""" + fake_api = [ + {"title": "Engineer", "description": "Build things"}, + {"title": "Other", "description": "Ignore me"}, + ] + with patch.object(_imitate_module, "_http_get_json", return_value=fake_api): + resp = client.get("/api/imitate/products/peregrine/sample") + assert resp.status_code == 200 + body = resp.json() + assert "Engineer" in body["text"] + assert "Build things" in body["text"] + assert "Analyze:" in body["prompt"] + + +def test_sample_fetched_from_dict_with_items_key(cfg_with_products, client): + """Extracts from a wrapper dict with a recognised list key.""" + fake_api = {"items": [{"title": "Wrapped Job", "description": "In a wrapper"}]} + with patch.object(_imitate_module, "_http_get_json", return_value=fake_api): + resp = client.get("/api/imitate/products/peregrine/sample") + assert resp.status_code == 200 + assert "Wrapped Job" in resp.json()["text"] + + +def test_sample_503_when_api_unreachable(cfg_with_products, client): + """Returns 503 when the product API is not reachable.""" + from urllib.error import URLError + with patch.object(_imitate_module, "_http_get_json", side_effect=URLError("refused")): + resp = client.get("/api/imitate/products/peregrine/sample") + assert resp.status_code == 503 + + +def test_sample_404_on_empty_list(cfg_with_products, client): + """Returns 404 when product API returns an empty list.""" + with patch.object(_imitate_module, "_http_get_json", return_value=[]): + resp = client.get("/api/imitate/products/peregrine/sample") + assert resp.status_code == 404 + + +# ── POST /push-corrections ───────────────────────────────────────────────────── + +def test_push_corrections_appends_jsonl(cfg_with_products, data_dir, client): + """Successful push writes records to sft_candidates.jsonl.""" + payload = { + "product_id": "peregrine", + "prompt": "Analyze this job:", + "results": [ + {"model": "qwen2.5:0.5b", "response": "It's a good job.", "elapsed_ms": 800, "error": None}, + {"model": "llama3.1:8b", "response": "Strong candidate.", "elapsed_ms": 1500, "error": None}, + ], + } + resp = client.post("/api/imitate/push-corrections", json=payload) + assert resp.status_code == 200 + assert resp.json()["pushed"] == 2 + + candidates = (data_dir / "sft_candidates.jsonl").read_text().splitlines() + assert len(candidates) == 2 + for line in candidates: + record = json.loads(line) + assert record["source"] == "imitate" + assert record["product_id"] == "peregrine" + assert record["status"] == "pending" + assert record["prompt_messages"][0]["role"] == "user" + + +def test_push_corrections_skips_errors(cfg_with_products, data_dir, client): + """Results with errors are not written to the corrections file.""" + payload = { + "product_id": "peregrine", + "prompt": "Analyze:", + "results": [ + {"model": "good-model", "response": "Good answer.", "elapsed_ms": 500, "error": None}, + {"model": "bad-model", "response": "", "elapsed_ms": 0, "error": "connection refused"}, + ], + } + resp = client.post("/api/imitate/push-corrections", json=payload) + assert resp.status_code == 200 + assert resp.json()["pushed"] == 1 + + +def test_push_corrections_empty_prompt_422(cfg_with_products, data_dir, client): + """Empty prompt returns 422.""" + payload = { + "product_id": "peregrine", + "prompt": " ", + "results": [{"model": "m", "response": "r", "elapsed_ms": 1, "error": None}], + } + resp = client.post("/api/imitate/push-corrections", json=payload) + assert resp.status_code == 422 + + +def test_push_corrections_all_errors_422(cfg_with_products, data_dir, client): + """422 when every result has an error (nothing to push).""" + payload = { + "product_id": "peregrine", + "prompt": "Analyze:", + "results": [ + {"model": "m", "response": "", "elapsed_ms": 0, "error": "timed out"}, + ], + } + resp = client.post("/api/imitate/push-corrections", json=payload) + assert resp.status_code == 422 + + +# ── _extract_sample helper ───────────────────────────────────────────────────── + +def test_extract_sample_list(): + result = _imitate_module._extract_sample( + [{"title": "A", "description": "B"}], + text_fields=["title", "description"], + ) + assert "A" in result["text"] + assert "B" in result["text"] + + +def test_extract_sample_empty_list(): + result = _imitate_module._extract_sample([], text_fields=["title"]) + assert result == {} + + +def test_extract_sample_respects_index(): + items = [{"title": "First"}, {"title": "Second"}] + result = _imitate_module._extract_sample(items, ["title"], sample_index=1) + assert "Second" in result["text"] + + +def test_extract_sample_clamps_index(): + items = [{"title": "Only"}] + result = _imitate_module._extract_sample(items, ["title"], sample_index=99) + assert "Only" in result["text"] diff --git a/web/src/components/AppSidebar.vue b/web/src/components/AppSidebar.vue index 74e4a5c..9fdb461 100644 --- a/web/src/components/AppSidebar.vue +++ b/web/src/components/AppSidebar.vue @@ -67,6 +67,7 @@ const navItems = [ { path: '/stats', icon: '📊', label: 'Stats' }, { path: '/benchmark', icon: '🏁', label: 'Benchmark' }, { path: '/models', icon: '🤗', label: 'Models' }, + { path: '/imitate', icon: '🪞', label: 'Imitate' }, { path: '/corrections', icon: '✍️', label: 'Corrections' }, { path: '/settings', icon: '⚙️', label: 'Settings' }, ] diff --git a/web/src/router/index.ts b/web/src/router/index.ts index a052e4c..b6a0df6 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -8,6 +8,7 @@ const BenchmarkView = () => import('../views/BenchmarkView.vue') const SettingsView = () => import('../views/SettingsView.vue') const CorrectionsView = () => import('../views/CorrectionsView.vue') const ModelsView = () => import('../views/ModelsView.vue') +const ImitateView = () => import('../views/ImitateView.vue') export const router = createRouter({ history: createWebHashHistory(), @@ -17,6 +18,7 @@ export const router = createRouter({ { path: '/stats', component: StatsView, meta: { title: 'Stats' } }, { path: '/benchmark', component: BenchmarkView, meta: { title: 'Benchmark' } }, { path: '/models', component: ModelsView, meta: { title: 'Models' } }, + { path: '/imitate', component: ImitateView, meta: { title: 'Imitate' } }, { path: '/corrections', component: CorrectionsView, meta: { title: 'Corrections' } }, { path: '/settings', component: SettingsView, meta: { title: 'Settings' } }, ], diff --git a/web/src/views/BenchmarkView.vue b/web/src/views/BenchmarkView.vue index 81d8302..f40f823 100644 --- a/web/src/views/BenchmarkView.vue +++ b/web/src/views/BenchmarkView.vue @@ -38,6 +38,11 @@ :class="{ active: benchMode === 'llm' }" @click="benchMode = 'llm'" >🤖 LLM Eval + @@ -214,6 +219,121 @@ + +