161 lines
5.9 KiB
Python
161 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Full RAG-in-chat visual via Playwright: with a GGUF model already loaded,
|
|
enable the RAG toggle, upload a PDF through the composer, ask a grounded
|
|
question, and capture the answer + sources. Records screenshots + video.
|
|
|
|
Usage:
|
|
python studio_rag_composer.py --base http://127.0.0.1:8912 --label improved \
|
|
--password StudioBench2026! --doc ../data/rag_corpus/bert_1810.04805.pdf \
|
|
--question "What are BERT's two pre-training objectives?" --out ../outputs/composer_improved
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
from playwright.async_api import async_playwright
|
|
|
|
|
|
def get_token(base, pw):
|
|
new = pw + "Aa1!"
|
|
with httpx.Client(timeout = 30) as c:
|
|
r = c.post(
|
|
f"{base}/api/auth/login", json = {"username": "unsloth", "password": pw}
|
|
)
|
|
if r.status_code == 401:
|
|
r = c.post(
|
|
f"{base}/api/auth/login", json = {"username": "unsloth", "password": new}
|
|
)
|
|
r.raise_for_status()
|
|
b = r.json()
|
|
t = b["access_token"]
|
|
if b.get("must_change_password"):
|
|
r2 = c.post(
|
|
f"{base}/api/auth/change-password",
|
|
headers = {"Authorization": f"Bearer {t}"},
|
|
json = {"current_password": pw, "new_password": new},
|
|
)
|
|
r2.raise_for_status()
|
|
t, b = r2.json()["access_token"], r2.json()
|
|
return t, b.get("refresh_token", "")
|
|
|
|
|
|
def init_script(tok, refresh):
|
|
seed = {"unsloth_auth_token": tok, "unsloth_refresh_token": refresh}
|
|
return f"""(() => {{ const s={json.dumps(seed)};
|
|
for (const k of Object.keys(s)) {{ try {{ localStorage.setItem(k, s[k]); }} catch(e){{}} }} }})();"""
|
|
|
|
|
|
async def run(args):
|
|
out = Path(args.out)
|
|
(out / "video").mkdir(parents = True, exist_ok = True)
|
|
tok, refresh = get_token(args.base, args.password)
|
|
result = {
|
|
"label": args.label,
|
|
"doc": Path(args.doc).name,
|
|
"question": args.question,
|
|
}
|
|
|
|
async with async_playwright() as p:
|
|
browser = await p.chromium.launch(headless = True)
|
|
ctx = await browser.new_context(
|
|
viewport = {"width": 1440, "height": 900},
|
|
record_video_dir = str(out / "video"),
|
|
record_video_size = {"width": 1440, "height": 900},
|
|
)
|
|
await ctx.add_init_script(init_script(tok, refresh))
|
|
page = await ctx.new_page()
|
|
await page.goto(f"{args.base}/chat", wait_until = "domcontentloaded")
|
|
await page.locator("form:has(textarea) textarea").first.wait_for(
|
|
state = "visible", timeout = 30000
|
|
)
|
|
await page.wait_for_timeout(1500)
|
|
await page.screenshot(path = str(out / "01_chat.png"))
|
|
|
|
# Enable the RAG toggle (the pill is active once a model is loaded).
|
|
for lbl in ("Enable RAG", "Disable RAG"):
|
|
btn = page.locator(f'button[aria-label="{lbl}"]').first
|
|
if await btn.count():
|
|
if lbl == "Enable RAG":
|
|
await btn.click()
|
|
break
|
|
await page.wait_for_timeout(700)
|
|
await page.screenshot(path = str(out / "02_rag_on.png"))
|
|
|
|
# Upload a document through the composer attach control.
|
|
file_input = page.locator('input[type="file"][accept*=".pdf"]').first
|
|
await file_input.wait_for(state = "attached", timeout = 10000)
|
|
t0 = time.perf_counter()
|
|
await file_input.set_input_files(args.doc)
|
|
ready = page.get_by_text("Ready", exact = True).first
|
|
toast = page.get_by_text("RAG index ready", exact = False).first
|
|
indexed = False
|
|
while time.perf_counter() - t0 < 180:
|
|
for sig in (toast, ready):
|
|
try:
|
|
if await sig.is_visible():
|
|
indexed = True
|
|
break
|
|
except Exception:
|
|
pass
|
|
if indexed:
|
|
break
|
|
await page.wait_for_timeout(250)
|
|
result["index_seconds"] = round(time.perf_counter() - t0, 2)
|
|
result["indexed"] = indexed
|
|
await page.screenshot(path = str(out / "03_indexed.png"))
|
|
|
|
# Ask a grounded question.
|
|
box = page.locator("form:has(textarea) textarea").first
|
|
await box.click()
|
|
await box.fill(args.question)
|
|
await box.press("Enter")
|
|
stop = page.locator(
|
|
'button[aria-label="Stop generating"], button:has-text("Stop")'
|
|
).first
|
|
try:
|
|
await stop.wait_for(state = "visible", timeout = 30000)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await stop.wait_for(state = "hidden", timeout = 180000)
|
|
except Exception:
|
|
pass
|
|
await page.wait_for_timeout(1000)
|
|
await page.screenshot(path = str(out / "04_answer.png"), full_page = True)
|
|
|
|
answer = await page.evaluate(
|
|
"""() => Array.from(document.querySelectorAll('[data-role="assistant"], [data-message-role="assistant"]'))
|
|
.map(n => n.textContent || '').join('\\n')"""
|
|
)
|
|
result["answer_excerpt"] = (answer or "")[-600:]
|
|
print(
|
|
f"[{args.label}] indexed={indexed} in {result['index_seconds']}s; answer chars={len(answer or '')}"
|
|
)
|
|
|
|
await ctx.close()
|
|
await browser.close()
|
|
|
|
webms = sorted((out / "video").glob("*.webm"))
|
|
if webms:
|
|
webms[-1].rename(out / "video" / f"{args.label}.webm")
|
|
Path(out / "result.json").write_text(json.dumps(result, indent = 2))
|
|
print(f"[{args.label}] wrote {out/'result.json'}")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--base", required = True)
|
|
ap.add_argument("--label", required = True)
|
|
ap.add_argument("--password", required = True)
|
|
ap.add_argument("--doc", required = True)
|
|
ap.add_argument("--question", required = True)
|
|
ap.add_argument("--out", required = True)
|
|
asyncio.run(run(ap.parse_args()))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|