mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
422 lines
14 KiB
Python
422 lines
14 KiB
Python
"""
|
|
File-connector benchmark cycle for spreadsheet retrieval:
|
|
|
|
1. Upload a zip of spreadsheets; the server unzips it into individual files.
|
|
2. Create a file credential + connector over those files, producing a CC pair.
|
|
3. Wait for the CC pair to finish its first indexing run.
|
|
4. Run scripts.benchmark_csv_retrieval against the indexed corpus, retrying
|
|
on non-zero exit. Output file is named from --label.
|
|
5. Delete the CC pair, wait for it to go, then delete the credential.
|
|
|
|
Each run therefore scores one chunking variant against a freshly built index.
|
|
Celery workers cache code at start, so restart docprocessing after editing the
|
|
chunker and before the next cycle.
|
|
|
|
Usage:
|
|
export ONYX_BENCH_EMAIL=... ONYX_BENCH_PASSWORD=...
|
|
cd backend && uv run python -m scripts.run_csv_benchmark_cycle \
|
|
--zip ~/Downloads/corpus.zip \
|
|
--questions ~/Documents/questions_improved.csv \
|
|
--label fieldvalue_baseline
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import requests
|
|
|
|
from scripts.benchmark_api import REQUEST_TIMEOUT_S, login
|
|
|
|
INDEXING_POLL_INTERVAL_S = 10
|
|
INDEXING_TIMEOUT_S = 60 * 60 # 1 hour
|
|
|
|
DELETION_POLL_INTERVAL_S = 5
|
|
DELETION_TIMEOUT_S = 30 * 60 # 30 min
|
|
|
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
|
BENCHMARK_TIMEOUT_S = 60 * 60
|
|
MAX_BENCHMARK_ATTEMPTS = 3
|
|
|
|
# Indexing finished, from the caller's point of view. A partial run still
|
|
# yields a scoreable index, so it does not hold the cycle for an hour.
|
|
TERMINAL_INDEX_STATUSES = {"success", "completed_with_errors"}
|
|
|
|
|
|
def upload_zip(
|
|
base_url: str, headers: dict[str, str], zip_path: Path
|
|
) -> tuple[list[str], list[str], str | None]:
|
|
"""Upload the zip and let the server expand it. Returns
|
|
(file_locations, file_names, zip_metadata_file_id)."""
|
|
upload_headers = {k: v for k, v in headers.items() if k != "Content-Type"}
|
|
with zip_path.open("rb") as fh:
|
|
resp = requests.post(
|
|
f"{base_url}/api/manage/admin/connector/file/upload",
|
|
params={"unzip": "true"},
|
|
files=[
|
|
(
|
|
"files",
|
|
(zip_path.name, fh, "application/zip"),
|
|
)
|
|
],
|
|
headers=upload_headers,
|
|
timeout=600,
|
|
)
|
|
if not resp.ok:
|
|
raise RuntimeError(f"upload_zip failed: {resp.status_code} {resp.text}")
|
|
body = resp.json()
|
|
file_paths: list[str] = body["file_paths"]
|
|
file_names: list[str] = body["file_names"]
|
|
print(f"Uploaded {zip_path.name} → {len(file_paths)} files")
|
|
return file_paths, file_names, body.get("zip_metadata_file_id")
|
|
|
|
|
|
def create_credential(base_url: str, headers: dict[str, str], name: str) -> int:
|
|
body = {
|
|
"name": name,
|
|
"credential_json": {},
|
|
"admin_public": True,
|
|
"source": "file",
|
|
"curator_public": True,
|
|
"groups": [],
|
|
}
|
|
resp = requests.post(
|
|
f"{base_url}/api/manage/credential",
|
|
json=body,
|
|
headers=headers,
|
|
timeout=REQUEST_TIMEOUT_S,
|
|
)
|
|
if not resp.ok:
|
|
raise RuntimeError(f"create_credential failed: {resp.status_code} {resp.text}")
|
|
credential_id = int(resp.json()["id"])
|
|
print(f"Created credential id={credential_id}")
|
|
return credential_id
|
|
|
|
|
|
def create_connector(
|
|
base_url: str,
|
|
headers: dict[str, str],
|
|
name: str,
|
|
file_locations: list[str],
|
|
file_names: list[str],
|
|
zip_metadata_file_id: str | None,
|
|
) -> int:
|
|
body = {
|
|
"name": name,
|
|
"source": "file",
|
|
"input_type": "load_state",
|
|
"connector_specific_config": {
|
|
"file_locations": file_locations,
|
|
"file_names": file_names,
|
|
"zip_metadata_file_id": zip_metadata_file_id,
|
|
},
|
|
"refresh_freq": None,
|
|
"prune_freq": None,
|
|
"indexing_start": None,
|
|
"access_type": "public",
|
|
"groups": [],
|
|
}
|
|
resp = requests.post(
|
|
f"{base_url}/api/manage/admin/connector",
|
|
json=body,
|
|
headers=headers,
|
|
timeout=REQUEST_TIMEOUT_S,
|
|
)
|
|
if not resp.ok:
|
|
raise RuntimeError(f"create_connector failed: {resp.status_code} {resp.text}")
|
|
connector_id = int(resp.json()["id"])
|
|
print(f"Created connector id={connector_id}")
|
|
return connector_id
|
|
|
|
|
|
def link_connector_to_credential(
|
|
base_url: str,
|
|
headers: dict[str, str],
|
|
connector_id: int,
|
|
credential_id: int,
|
|
name: str,
|
|
) -> int:
|
|
body = {
|
|
"name": name,
|
|
"access_type": "public",
|
|
"auto_sync_options": None,
|
|
"groups": [],
|
|
}
|
|
resp = requests.put(
|
|
f"{base_url}/api/manage/connector/{connector_id}/credential/{credential_id}",
|
|
json=body,
|
|
headers=headers,
|
|
timeout=REQUEST_TIMEOUT_S,
|
|
)
|
|
if not resp.ok:
|
|
raise RuntimeError(
|
|
f"link_connector_to_credential failed: {resp.status_code} {resp.text}"
|
|
)
|
|
cc_pair_id = int(resp.json()["data"])
|
|
print(f"Linked → cc_pair_id={cc_pair_id}")
|
|
return cc_pair_id
|
|
|
|
|
|
def get_cc_pair(
|
|
base_url: str, headers: dict[str, str], cc_pair_id: int
|
|
) -> dict[str, Any] | None:
|
|
resp = requests.get(
|
|
f"{base_url}/api/manage/admin/cc-pair/{cc_pair_id}",
|
|
headers=headers,
|
|
timeout=REQUEST_TIMEOUT_S,
|
|
)
|
|
if resp.status_code == 404:
|
|
return None
|
|
resp.raise_for_status()
|
|
return dict(resp.json())
|
|
|
|
|
|
def wait_for_indexing_complete(
|
|
base_url: str, headers: dict[str, str], cc_pair_id: int
|
|
) -> None:
|
|
"""Wait until the pair is ACTIVE, idle, and holds documents.
|
|
|
|
status == 'ACTIVE' alone only means the pair is enabled; it flips to
|
|
ACTIVE before indexing has actually started.
|
|
"""
|
|
print(f"Waiting for cc_pair {cc_pair_id} to finish indexing...")
|
|
deadline = time.time() + INDEXING_TIMEOUT_S
|
|
last_summary = ""
|
|
while time.time() < deadline:
|
|
item = get_cc_pair(base_url, headers, cc_pair_id)
|
|
if item is None:
|
|
print(" cc_pair not found yet, waiting...")
|
|
else:
|
|
status = item.get("status")
|
|
indexing = item.get("indexing")
|
|
last_status = item.get("last_index_attempt_status")
|
|
docs = item.get("num_docs_indexed", 0)
|
|
summary = (
|
|
f" status={status} indexing={indexing} "
|
|
f"last_status={last_status} docs={docs}"
|
|
)
|
|
if summary != last_summary:
|
|
print(summary)
|
|
last_summary = summary
|
|
|
|
if (
|
|
status == "ACTIVE"
|
|
and indexing is False
|
|
and last_status in TERMINAL_INDEX_STATUSES
|
|
and docs > 0
|
|
):
|
|
if last_status != "success":
|
|
print(f"WARNING: indexing finished as {last_status}")
|
|
print(f"Indexing complete: {docs} docs.")
|
|
return
|
|
if item.get("in_repeated_error_state"):
|
|
raise RuntimeError(f"cc_pair entered repeated error state: {item}")
|
|
time.sleep(INDEXING_POLL_INTERVAL_S)
|
|
raise TimeoutError(
|
|
f"Indexing did not complete within {INDEXING_TIMEOUT_S}s "
|
|
f"for cc_pair {cc_pair_id}"
|
|
)
|
|
|
|
|
|
def run_benchmark(
|
|
label: str, questions: Path, base_url: str, email: str, password: str
|
|
) -> Path:
|
|
"""Score the indexed corpus. Credentials go through the environment so the
|
|
password never appears in the child's command line."""
|
|
output_path = (Path.cwd() / f"benchmark_results_{label}.txt").resolve()
|
|
cmd = [
|
|
sys.executable,
|
|
"-m",
|
|
"scripts.benchmark_csv_retrieval",
|
|
"--input",
|
|
str(questions),
|
|
"--base-url",
|
|
base_url,
|
|
"--output",
|
|
str(output_path),
|
|
]
|
|
env = {
|
|
**os.environ,
|
|
"ONYX_BENCH_EMAIL": email,
|
|
"ONYX_BENCH_PASSWORD": password,
|
|
}
|
|
for attempt in range(1, MAX_BENCHMARK_ATTEMPTS + 1):
|
|
print(
|
|
f"\n[Benchmark attempt {attempt}/{MAX_BENCHMARK_ATTEMPTS}] {' '.join(cmd)}"
|
|
)
|
|
result = subprocess.run(
|
|
cmd, cwd=BACKEND_DIR, env=env, timeout=BENCHMARK_TIMEOUT_S
|
|
)
|
|
if result.returncode == 0 and output_path.exists():
|
|
print(f"Benchmark succeeded → {output_path}")
|
|
return output_path
|
|
print(
|
|
f"Benchmark attempt {attempt} failed (rc={result.returncode}); retrying..."
|
|
)
|
|
raise RuntimeError(f"Benchmark failed after {MAX_BENCHMARK_ATTEMPTS} attempts")
|
|
|
|
|
|
def trigger_deletion(
|
|
base_url: str, headers: dict[str, str], connector_id: int, credential_id: int
|
|
) -> None:
|
|
body = {"connector_id": connector_id, "credential_id": credential_id}
|
|
resp = requests.post(
|
|
f"{base_url}/api/manage/admin/deletion-attempt",
|
|
json=body,
|
|
headers=headers,
|
|
timeout=REQUEST_TIMEOUT_S,
|
|
)
|
|
if not resp.ok:
|
|
raise RuntimeError(f"trigger_deletion failed: {resp.status_code} {resp.text}")
|
|
print(f"Deletion triggered for connector={connector_id}, cred={credential_id}")
|
|
|
|
|
|
def delete_credential(
|
|
base_url: str, headers: dict[str, str], credential_id: int
|
|
) -> None:
|
|
resp = requests.delete(
|
|
f"{base_url}/api/manage/admin/credential/{credential_id}",
|
|
headers=headers,
|
|
timeout=REQUEST_TIMEOUT_S,
|
|
)
|
|
if not resp.ok:
|
|
raise RuntimeError(f"delete_credential failed: {resp.status_code} {resp.text}")
|
|
print(f"Deleted credential {credential_id}")
|
|
|
|
|
|
def wait_for_deletion(base_url: str, headers: dict[str, str], cc_pair_id: int) -> None:
|
|
print(f"Waiting for cc_pair {cc_pair_id} to be deleted...")
|
|
deadline = time.time() + DELETION_TIMEOUT_S
|
|
last_status = ""
|
|
while time.time() < deadline:
|
|
item = get_cc_pair(base_url, headers, cc_pair_id)
|
|
if item is None:
|
|
print(" cc_pair gone — deletion complete.")
|
|
return
|
|
status = str(item.get("status"))
|
|
if status != last_status:
|
|
print(f" status={status}")
|
|
last_status = status
|
|
time.sleep(DELETION_POLL_INTERVAL_S)
|
|
raise TimeoutError(f"cc_pair {cc_pair_id} not deleted within {DELETION_TIMEOUT_S}s")
|
|
|
|
|
|
def cleanup(
|
|
base_url: str,
|
|
headers: dict[str, str],
|
|
keep: bool,
|
|
connector_id: int | None,
|
|
credential_id: int | None,
|
|
cc_pair_id: int | None,
|
|
) -> Exception | None:
|
|
"""Remove whatever setup managed to create. Returns the failure instead of
|
|
raising, so it cannot replace an exception already on its way out."""
|
|
if cc_pair_id is None:
|
|
if connector_id is not None or credential_id is not None:
|
|
print(
|
|
f"Setup failed before linking. Left behind: "
|
|
f"connector={connector_id} credential={credential_id}",
|
|
file=sys.stderr,
|
|
)
|
|
return None
|
|
if keep:
|
|
print(f"--keep set; leaving cc_pair {cc_pair_id} in place.")
|
|
return None
|
|
if connector_id is None or credential_id is None:
|
|
return None
|
|
try:
|
|
trigger_deletion(base_url, headers, connector_id, credential_id)
|
|
wait_for_deletion(base_url, headers, cc_pair_id)
|
|
# Deleting the pair leaves the credential, so remove it explicitly.
|
|
delete_credential(base_url, headers, credential_id)
|
|
except Exception as e:
|
|
print(f"Cleanup failed for cc_pair {cc_pair_id}: {e!r}", file=sys.stderr)
|
|
return e
|
|
return None
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--label", required=True, help="Names the output report")
|
|
parser.add_argument(
|
|
"--zip", required=True, type=Path, help="Zip of spreadsheets to index"
|
|
)
|
|
parser.add_argument(
|
|
"--questions", required=True, type=Path, help="question,file CSV"
|
|
)
|
|
parser.add_argument("--base-url", default="http://localhost:3000")
|
|
parser.add_argument("--email", default=os.environ.get("ONYX_BENCH_EMAIL"))
|
|
parser.add_argument("--password", default=os.environ.get("ONYX_BENCH_PASSWORD"))
|
|
parser.add_argument(
|
|
"--keep",
|
|
action="store_true",
|
|
help="Leave the connector in place instead of deleting it",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if not args.email or not args.password:
|
|
print(
|
|
"Set ONYX_BENCH_EMAIL and ONYX_BENCH_PASSWORD "
|
|
"(or pass --email/--password).",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
zip_path: Path = args.zip.expanduser()
|
|
questions: Path = args.questions.expanduser()
|
|
for path in (zip_path, questions):
|
|
if not path.exists():
|
|
print(f"No such file: {path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
name = f"csv-benchmark-{args.label}"
|
|
headers = login(args.base_url, args.email, args.password)
|
|
|
|
file_locations, file_names, zip_metadata_file_id = upload_zip(
|
|
args.base_url, headers, zip_path
|
|
)
|
|
credential_id: int | None = None
|
|
connector_id: int | None = None
|
|
cc_pair_id: int | None = None
|
|
body_failed = False
|
|
try:
|
|
credential_id = create_credential(args.base_url, headers, name)
|
|
connector_id = create_connector(
|
|
args.base_url,
|
|
headers,
|
|
name,
|
|
file_locations,
|
|
file_names,
|
|
zip_metadata_file_id,
|
|
)
|
|
cc_pair_id = link_connector_to_credential(
|
|
args.base_url, headers, connector_id, credential_id, name
|
|
)
|
|
wait_for_indexing_complete(args.base_url, headers, cc_pair_id)
|
|
run_benchmark(args.label, questions, args.base_url, args.email, args.password)
|
|
except BaseException:
|
|
body_failed = True
|
|
raise
|
|
finally:
|
|
cleanup_error = cleanup(
|
|
base_url=args.base_url,
|
|
headers=headers,
|
|
keep=args.keep,
|
|
connector_id=connector_id,
|
|
credential_id=credential_id,
|
|
cc_pair_id=cc_pair_id,
|
|
)
|
|
# A leaked connector must fail the run, but never mask the real error.
|
|
if cleanup_error is not None and not body_failed:
|
|
raise cleanup_error
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|