Eric Xu commited on
Pass LLM credentials per-request, never store server-side
Browse filesAPI key flows from browser JS memory → query params → backend middleware
→ LLM call. Nothing is stored in server memory or disk. Each request
is self-contained.
Users can: bring their own key (any provider), or use a server-default
key set via HF Spaces Secrets.
- web/app.py +58 -57
- web/static/index.html +37 -22
web/app.py
CHANGED
|
@@ -23,7 +23,7 @@ from datetime import datetime
|
|
| 23 |
from pathlib import Path
|
| 24 |
|
| 25 |
from dotenv import load_dotenv
|
| 26 |
-
from fastapi import FastAPI, HTTPException
|
| 27 |
from fastapi.staticfiles import StaticFiles
|
| 28 |
from fastapi.responses import FileResponse
|
| 29 |
from pydantic import BaseModel
|
|
@@ -113,24 +113,46 @@ def get_nemotron(data_dir=None):
|
|
| 113 |
return None
|
| 114 |
|
| 115 |
|
| 116 |
-
#
|
| 117 |
-
_user_llm_config: dict = {} # session-scoped, not persistent
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
base = _user_llm_config.get("base_url") or os.getenv("LLM_BASE_URL")
|
| 123 |
if not key:
|
| 124 |
raise HTTPException(400, "No API key configured. Enter your key above.")
|
| 125 |
return OpenAI(api_key=key, base_url=base)
|
| 126 |
|
| 127 |
|
| 128 |
-
def get_model():
|
| 129 |
-
return
|
| 130 |
|
| 131 |
|
| 132 |
IS_SPACES = bool(os.getenv("SPACE_ID"))
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
NEMOTRON_DATASETS = {
|
| 135 |
"USA": "nvidia/Nemotron-Personas-USA",
|
| 136 |
"Japan": "nvidia/Nemotron-Personas-Japan",
|
|
@@ -179,21 +201,15 @@ async def index():
|
|
| 179 |
return FileResponse(Path(__file__).parent / "static" / "index.html")
|
| 180 |
|
| 181 |
|
| 182 |
-
class SetApiKeyInput(BaseModel):
|
| 183 |
-
api_key: str
|
| 184 |
-
base_url: str = ""
|
| 185 |
-
model: str = ""
|
| 186 |
-
|
| 187 |
-
|
| 188 |
@app.get("/api/config")
|
| 189 |
async def get_config():
|
| 190 |
"""Return current LLM config and Nemotron status."""
|
| 191 |
nem_path = find_nemotron_path()
|
| 192 |
-
has_key = bool(
|
| 193 |
return {
|
| 194 |
"model": get_model(),
|
| 195 |
"has_api_key": has_key,
|
| 196 |
-
"base_url":
|
| 197 |
"nemotron_path": str(nem_path) if nem_path else None,
|
| 198 |
"nemotron_available": nem_path is not None,
|
| 199 |
"is_spaces": IS_SPACES,
|
|
@@ -201,16 +217,6 @@ async def get_config():
|
|
| 201 |
}
|
| 202 |
|
| 203 |
|
| 204 |
-
@app.post("/api/config/api-key")
|
| 205 |
-
async def set_api_key(input: SetApiKeyInput):
|
| 206 |
-
"""Set LLM API key from the UI (for hosted/Spaces mode)."""
|
| 207 |
-
_user_llm_config["api_key"] = input.api_key
|
| 208 |
-
if input.base_url:
|
| 209 |
-
_user_llm_config["base_url"] = input.base_url
|
| 210 |
-
if input.model:
|
| 211 |
-
_user_llm_config["model"] = input.model
|
| 212 |
-
return {"ok": True, "model": get_model()}
|
| 213 |
-
|
| 214 |
|
| 215 |
class SuggestChangesInput(BaseModel):
|
| 216 |
entity_text: str
|
|
@@ -285,10 +291,9 @@ class InferSpecInput(BaseModel):
|
|
| 285 |
|
| 286 |
|
| 287 |
@app.post("/api/infer-spec")
|
| 288 |
-
async def infer_spec(input: InferSpecInput):
|
| 289 |
"""Infer goal and audience from entity text."""
|
| 290 |
-
client =
|
| 291 |
-
model = get_model()
|
| 292 |
|
| 293 |
prompt = f"""Read this entity and infer two things:
|
| 294 |
1. What is the most likely GOAL the author has? (what outcome they want)
|
|
@@ -327,10 +332,9 @@ Be specific to THIS entity, not generic."""
|
|
| 327 |
|
| 328 |
|
| 329 |
@app.post("/api/suggest-changes")
|
| 330 |
-
async def suggest_changes(input: SuggestChangesInput):
|
| 331 |
"""Generate candidate changes from evaluation concerns and goal."""
|
| 332 |
-
client =
|
| 333 |
-
model = get_model()
|
| 334 |
|
| 335 |
concerns_text = "\n".join(f"- {c}" for c in input.concerns[:15])
|
| 336 |
prompt = f"""Based on these evaluation results, suggest 3-5 specific, actionable changes.
|
|
@@ -370,10 +374,9 @@ Return JSON:
|
|
| 370 |
|
| 371 |
|
| 372 |
@app.post("/api/suggest-segments")
|
| 373 |
-
async def suggest_segments(input: SuggestSegmentsInput):
|
| 374 |
"""Use LLM to suggest audience segments based on entity and context."""
|
| 375 |
-
client =
|
| 376 |
-
model = get_model()
|
| 377 |
|
| 378 |
prompt = f"""Given this entity and audience context, suggest 4-5 evaluator segments.
|
| 379 |
Each segment should represent a distinct perspective that would evaluate this entity differently.
|
|
@@ -455,7 +458,7 @@ If nothing specific is stated, return {{}}."""
|
|
| 455 |
|
| 456 |
|
| 457 |
@app.post("/api/cohort/generate")
|
| 458 |
-
async def generate_cohort_endpoint(config: CohortConfig):
|
| 459 |
"""Generate a cohort — from Nemotron if available, else LLM-generated."""
|
| 460 |
total = sum(s.get("count", 8) for s in config.segments)
|
| 461 |
|
|
@@ -467,8 +470,7 @@ async def generate_cohort_endpoint(config: CohortConfig):
|
|
| 467 |
ss = _lazy_stratified_sampler()
|
| 468 |
|
| 469 |
# Extract structured filters from audience context
|
| 470 |
-
client =
|
| 471 |
-
model = get_model()
|
| 472 |
filters = extract_filters(client, model, config.audience_context, config.description)
|
| 473 |
print(f"Nemotron filters from audience context: {filters}")
|
| 474 |
|
|
@@ -491,8 +493,7 @@ async def generate_cohort_endpoint(config: CohortConfig):
|
|
| 491 |
source = "nemotron"
|
| 492 |
else:
|
| 493 |
# Fallback: LLM-generated
|
| 494 |
-
client =
|
| 495 |
-
model = get_model()
|
| 496 |
all_personas = []
|
| 497 |
|
| 498 |
with concurrent.futures.ThreadPoolExecutor(max_workers=config.parallel) as pool:
|
|
@@ -527,7 +528,8 @@ async def upload_cohort(sid: str, cohort: list[dict]):
|
|
| 527 |
# ── SSE streaming endpoints ──────────────────────────────────────────────
|
| 528 |
|
| 529 |
@app.get("/api/evaluate/stream/{sid}")
|
| 530 |
-
async def evaluate_stream(sid: str, parallel: int = 5, bias_calibration: bool = False
|
|
|
|
| 531 |
"""Run evaluation with Server-Sent Events for real-time progress."""
|
| 532 |
if sid not in sessions:
|
| 533 |
raise HTTPException(404, "Session not found")
|
|
@@ -536,15 +538,14 @@ async def evaluate_stream(sid: str, parallel: int = 5, bias_calibration: bool =
|
|
| 536 |
raise HTTPException(400, "No cohort — generate or upload one first")
|
| 537 |
|
| 538 |
async def event_generator():
|
| 539 |
-
client =
|
| 540 |
-
model = get_model()
|
| 541 |
cohort = session["cohort"]
|
| 542 |
entity_text = session["entity_text"]
|
| 543 |
total = len(cohort)
|
| 544 |
sys_prompt = SYSTEM_PROMPT + BIAS_CALIBRATION_ADDENDUM if bias_calibration else None
|
| 545 |
|
| 546 |
yield {"event": "start", "data": json.dumps({
|
| 547 |
-
"total": total, "model":
|
| 548 |
"bias_calibration": bias_calibration,
|
| 549 |
})}
|
| 550 |
|
|
@@ -553,7 +554,7 @@ async def evaluate_stream(sid: str, parallel: int = 5, bias_calibration: bool =
|
|
| 553 |
t0 = time.time()
|
| 554 |
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as pool:
|
| 555 |
futs = {
|
| 556 |
-
pool.submit(evaluate_one, client,
|
| 557 |
system_prompt=sys_prompt): i
|
| 558 |
for i, ev in enumerate(cohort)
|
| 559 |
}
|
|
@@ -629,7 +630,8 @@ async def prepare_counterfactual(sid: str, req: CounterfactualRequest):
|
|
| 629 |
|
| 630 |
|
| 631 |
@app.get("/api/counterfactual/stream/{sid}")
|
| 632 |
-
async def counterfactual_stream(sid: str, ticket: str
|
|
|
|
| 633 |
"""Run counterfactual probes with SSE progress."""
|
| 634 |
if sid not in sessions:
|
| 635 |
raise HTTPException(404, "Session not found")
|
|
@@ -650,8 +652,7 @@ async def counterfactual_stream(sid: str, ticket: str):
|
|
| 650 |
parallel = req.parallel
|
| 651 |
|
| 652 |
async def event_generator():
|
| 653 |
-
client =
|
| 654 |
-
model = get_model()
|
| 655 |
cohort = session["cohort"]
|
| 656 |
eval_results = session["eval_results"]
|
| 657 |
cohort_map = {f"{p.get('name','')}_{p.get('user_id','')}": p for p in cohort}
|
|
@@ -662,7 +663,7 @@ async def counterfactual_stream(sid: str, ticket: str):
|
|
| 662 |
total = len(movable)
|
| 663 |
has_goal = bool(goal.strip())
|
| 664 |
yield {"event": "start", "data": json.dumps({
|
| 665 |
-
"total": total, "changes": len(all_changes), "model":
|
| 666 |
"goal": goal if has_goal else None,
|
| 667 |
})}
|
| 668 |
|
|
@@ -681,7 +682,7 @@ async def counterfactual_stream(sid: str, ticket: str):
|
|
| 681 |
"status": "computing", "message": "Scoring evaluator relevance to goal..."
|
| 682 |
})}
|
| 683 |
goal_weights = compute_goal_weights(
|
| 684 |
-
client,
|
| 685 |
)
|
| 686 |
relevant = sum(1 for v in goal_weights.values() if v["weight"] >= 0.5)
|
| 687 |
yield {"event": "goal_weights", "data": json.dumps({
|
|
@@ -697,7 +698,7 @@ async def counterfactual_stream(sid: str, ticket: str):
|
|
| 697 |
|
| 698 |
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as pool:
|
| 699 |
futs = {
|
| 700 |
-
pool.submit(probe_one, client,
|
| 701 |
for i, r in enumerate(movable)
|
| 702 |
}
|
| 703 |
for fut in concurrent.futures.as_completed(futs):
|
|
@@ -742,7 +743,8 @@ async def counterfactual_stream(sid: str, ticket: str):
|
|
| 742 |
@app.get("/api/bias-audit/stream/{sid}")
|
| 743 |
async def bias_audit_stream(
|
| 744 |
sid: str, probes: str = "framing,authority,order",
|
| 745 |
-
sample: int = 10, parallel: int = 5
|
|
|
|
| 746 |
):
|
| 747 |
"""Run bias audit probes with SSE progress."""
|
| 748 |
if sid not in sessions:
|
|
@@ -755,8 +757,7 @@ async def bias_audit_stream(
|
|
| 755 |
|
| 756 |
async def event_generator():
|
| 757 |
import random
|
| 758 |
-
client =
|
| 759 |
-
model = get_model()
|
| 760 |
cohort = session["cohort"]
|
| 761 |
entity_text = session["entity_text"]
|
| 762 |
|
|
@@ -766,7 +767,7 @@ async def bias_audit_stream(
|
|
| 766 |
yield {"event": "start", "data": json.dumps({
|
| 767 |
"probes": probe_list,
|
| 768 |
"sample_size": len(evaluators),
|
| 769 |
-
"model":
|
| 770 |
})}
|
| 771 |
|
| 772 |
all_analyses = []
|
|
@@ -811,7 +812,7 @@ async def bias_audit_stream(
|
|
| 811 |
"analysis": analysis,
|
| 812 |
})}
|
| 813 |
|
| 814 |
-
report = generate_report(all_analyses,
|
| 815 |
session["bias_audit"] = {"analyses": all_analyses, "report": report}
|
| 816 |
|
| 817 |
yield {"event": "complete", "data": json.dumps({
|
|
|
|
| 23 |
from pathlib import Path
|
| 24 |
|
| 25 |
from dotenv import load_dotenv
|
| 26 |
+
from fastapi import FastAPI, HTTPException, Query, Request
|
| 27 |
from fastapi.staticfiles import StaticFiles
|
| 28 |
from fastapi.responses import FileResponse
|
| 29 |
from pydantic import BaseModel
|
|
|
|
| 113 |
return None
|
| 114 |
|
| 115 |
|
| 116 |
+
# LLM client — uses per-request headers or server env vars. Never stored.
|
|
|
|
| 117 |
|
| 118 |
+
def get_client(api_key=None, base_url=None):
|
| 119 |
+
key = api_key or os.getenv("LLM_API_KEY")
|
| 120 |
+
base = base_url or os.getenv("LLM_BASE_URL")
|
|
|
|
| 121 |
if not key:
|
| 122 |
raise HTTPException(400, "No API key configured. Enter your key above.")
|
| 123 |
return OpenAI(api_key=key, base_url=base)
|
| 124 |
|
| 125 |
|
| 126 |
+
def get_model(model=None):
|
| 127 |
+
return model or os.getenv("LLM_MODEL_NAME", "openai/gpt-4o-mini")
|
| 128 |
|
| 129 |
|
| 130 |
IS_SPACES = bool(os.getenv("SPACE_ID"))
|
| 131 |
|
| 132 |
+
|
| 133 |
+
def _llm_from_params(api_key: str = "", base_url: str = "", model: str = ""):
|
| 134 |
+
"""Extract LLM config from params. Falls back to env vars. Never stored."""
|
| 135 |
+
return (
|
| 136 |
+
get_client(api_key=api_key or None, base_url=base_url or None),
|
| 137 |
+
get_model(model=model or None),
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@app.middleware("http")
|
| 142 |
+
async def inject_llm_config(request: Request, call_next):
|
| 143 |
+
"""Make LLM creds available from query params on any request."""
|
| 144 |
+
request.state.api_key = request.query_params.get("api_key", "")
|
| 145 |
+
request.state.base_url = request.query_params.get("base_url", "")
|
| 146 |
+
request.state.model = request.query_params.get("model", "")
|
| 147 |
+
return await call_next(request)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def llm_from_request(request: Request):
|
| 151 |
+
"""Get LLM client+model from the current request's query params."""
|
| 152 |
+
return _llm_from_params(
|
| 153 |
+
request.state.api_key, request.state.base_url, request.state.model
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
NEMOTRON_DATASETS = {
|
| 157 |
"USA": "nvidia/Nemotron-Personas-USA",
|
| 158 |
"Japan": "nvidia/Nemotron-Personas-Japan",
|
|
|
|
| 201 |
return FileResponse(Path(__file__).parent / "static" / "index.html")
|
| 202 |
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
@app.get("/api/config")
|
| 205 |
async def get_config():
|
| 206 |
"""Return current LLM config and Nemotron status."""
|
| 207 |
nem_path = find_nemotron_path()
|
| 208 |
+
has_key = bool(os.getenv("LLM_API_KEY")) # server-level key only; per-session keys checked client-side
|
| 209 |
return {
|
| 210 |
"model": get_model(),
|
| 211 |
"has_api_key": has_key,
|
| 212 |
+
"base_url": os.getenv("LLM_BASE_URL", ""),
|
| 213 |
"nemotron_path": str(nem_path) if nem_path else None,
|
| 214 |
"nemotron_available": nem_path is not None,
|
| 215 |
"is_spaces": IS_SPACES,
|
|
|
|
| 217 |
}
|
| 218 |
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
class SuggestChangesInput(BaseModel):
|
| 222 |
entity_text: str
|
|
|
|
| 291 |
|
| 292 |
|
| 293 |
@app.post("/api/infer-spec")
|
| 294 |
+
async def infer_spec(input: InferSpecInput, request: Request):
|
| 295 |
"""Infer goal and audience from entity text."""
|
| 296 |
+
client, model = llm_from_request(request)
|
|
|
|
| 297 |
|
| 298 |
prompt = f"""Read this entity and infer two things:
|
| 299 |
1. What is the most likely GOAL the author has? (what outcome they want)
|
|
|
|
| 332 |
|
| 333 |
|
| 334 |
@app.post("/api/suggest-changes")
|
| 335 |
+
async def suggest_changes(input: SuggestChangesInput, request: Request):
|
| 336 |
"""Generate candidate changes from evaluation concerns and goal."""
|
| 337 |
+
client, model = llm_from_request(request)
|
|
|
|
| 338 |
|
| 339 |
concerns_text = "\n".join(f"- {c}" for c in input.concerns[:15])
|
| 340 |
prompt = f"""Based on these evaluation results, suggest 3-5 specific, actionable changes.
|
|
|
|
| 374 |
|
| 375 |
|
| 376 |
@app.post("/api/suggest-segments")
|
| 377 |
+
async def suggest_segments(input: SuggestSegmentsInput, request: Request):
|
| 378 |
"""Use LLM to suggest audience segments based on entity and context."""
|
| 379 |
+
client, model = llm_from_request(request)
|
|
|
|
| 380 |
|
| 381 |
prompt = f"""Given this entity and audience context, suggest 4-5 evaluator segments.
|
| 382 |
Each segment should represent a distinct perspective that would evaluate this entity differently.
|
|
|
|
| 458 |
|
| 459 |
|
| 460 |
@app.post("/api/cohort/generate")
|
| 461 |
+
async def generate_cohort_endpoint(config: CohortConfig, request: Request):
|
| 462 |
"""Generate a cohort — from Nemotron if available, else LLM-generated."""
|
| 463 |
total = sum(s.get("count", 8) for s in config.segments)
|
| 464 |
|
|
|
|
| 470 |
ss = _lazy_stratified_sampler()
|
| 471 |
|
| 472 |
# Extract structured filters from audience context
|
| 473 |
+
client, model = llm_from_request(request)
|
|
|
|
| 474 |
filters = extract_filters(client, model, config.audience_context, config.description)
|
| 475 |
print(f"Nemotron filters from audience context: {filters}")
|
| 476 |
|
|
|
|
| 493 |
source = "nemotron"
|
| 494 |
else:
|
| 495 |
# Fallback: LLM-generated
|
| 496 |
+
client, model = llm_from_request(request)
|
|
|
|
| 497 |
all_personas = []
|
| 498 |
|
| 499 |
with concurrent.futures.ThreadPoolExecutor(max_workers=config.parallel) as pool:
|
|
|
|
| 528 |
# ── SSE streaming endpoints ──────────────────────────────────────────────
|
| 529 |
|
| 530 |
@app.get("/api/evaluate/stream/{sid}")
|
| 531 |
+
async def evaluate_stream(sid: str, parallel: int = 5, bias_calibration: bool = False,
|
| 532 |
+
api_key: str = "", base_url: str = "", model: str = ""):
|
| 533 |
"""Run evaluation with Server-Sent Events for real-time progress."""
|
| 534 |
if sid not in sessions:
|
| 535 |
raise HTTPException(404, "Session not found")
|
|
|
|
| 538 |
raise HTTPException(400, "No cohort — generate or upload one first")
|
| 539 |
|
| 540 |
async def event_generator():
|
| 541 |
+
client, mdl = _llm_from_params(api_key, base_url, model)
|
|
|
|
| 542 |
cohort = session["cohort"]
|
| 543 |
entity_text = session["entity_text"]
|
| 544 |
total = len(cohort)
|
| 545 |
sys_prompt = SYSTEM_PROMPT + BIAS_CALIBRATION_ADDENDUM if bias_calibration else None
|
| 546 |
|
| 547 |
yield {"event": "start", "data": json.dumps({
|
| 548 |
+
"total": total, "model": mdl,
|
| 549 |
"bias_calibration": bias_calibration,
|
| 550 |
})}
|
| 551 |
|
|
|
|
| 554 |
t0 = time.time()
|
| 555 |
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as pool:
|
| 556 |
futs = {
|
| 557 |
+
pool.submit(evaluate_one, client, mdl, ev, entity_text,
|
| 558 |
system_prompt=sys_prompt): i
|
| 559 |
for i, ev in enumerate(cohort)
|
| 560 |
}
|
|
|
|
| 630 |
|
| 631 |
|
| 632 |
@app.get("/api/counterfactual/stream/{sid}")
|
| 633 |
+
async def counterfactual_stream(sid: str, ticket: str,
|
| 634 |
+
api_key: str = "", base_url: str = "", model: str = ""):
|
| 635 |
"""Run counterfactual probes with SSE progress."""
|
| 636 |
if sid not in sessions:
|
| 637 |
raise HTTPException(404, "Session not found")
|
|
|
|
| 652 |
parallel = req.parallel
|
| 653 |
|
| 654 |
async def event_generator():
|
| 655 |
+
client, mdl = _llm_from_params(api_key, base_url, model)
|
|
|
|
| 656 |
cohort = session["cohort"]
|
| 657 |
eval_results = session["eval_results"]
|
| 658 |
cohort_map = {f"{p.get('name','')}_{p.get('user_id','')}": p for p in cohort}
|
|
|
|
| 663 |
total = len(movable)
|
| 664 |
has_goal = bool(goal.strip())
|
| 665 |
yield {"event": "start", "data": json.dumps({
|
| 666 |
+
"total": total, "changes": len(all_changes), "model": mdl,
|
| 667 |
"goal": goal if has_goal else None,
|
| 668 |
})}
|
| 669 |
|
|
|
|
| 682 |
"status": "computing", "message": "Scoring evaluator relevance to goal..."
|
| 683 |
})}
|
| 684 |
goal_weights = compute_goal_weights(
|
| 685 |
+
client, mdl, eval_results, cohort_map, goal, parallel=parallel,
|
| 686 |
)
|
| 687 |
relevant = sum(1 for v in goal_weights.values() if v["weight"] >= 0.5)
|
| 688 |
yield {"event": "goal_weights", "data": json.dumps({
|
|
|
|
| 698 |
|
| 699 |
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as pool:
|
| 700 |
futs = {
|
| 701 |
+
pool.submit(probe_one, client, mdl, r, cohort_map, all_changes): i
|
| 702 |
for i, r in enumerate(movable)
|
| 703 |
}
|
| 704 |
for fut in concurrent.futures.as_completed(futs):
|
|
|
|
| 743 |
@app.get("/api/bias-audit/stream/{sid}")
|
| 744 |
async def bias_audit_stream(
|
| 745 |
sid: str, probes: str = "framing,authority,order",
|
| 746 |
+
sample: int = 10, parallel: int = 5,
|
| 747 |
+
api_key: str = "", base_url: str = "", model: str = ""
|
| 748 |
):
|
| 749 |
"""Run bias audit probes with SSE progress."""
|
| 750 |
if sid not in sessions:
|
|
|
|
| 757 |
|
| 758 |
async def event_generator():
|
| 759 |
import random
|
| 760 |
+
client, mdl = _llm_from_params(api_key, base_url, model)
|
|
|
|
| 761 |
cohort = session["cohort"]
|
| 762 |
entity_text = session["entity_text"]
|
| 763 |
|
|
|
|
| 767 |
yield {"event": "start", "data": json.dumps({
|
| 768 |
"probes": probe_list,
|
| 769 |
"sample_size": len(evaluators),
|
| 770 |
+
"model": mdl,
|
| 771 |
})}
|
| 772 |
|
| 773 |
all_analyses = []
|
|
|
|
| 812 |
"analysis": analysis,
|
| 813 |
})}
|
| 814 |
|
| 815 |
+
report = generate_report(all_analyses, mdl)
|
| 816 |
session["bias_audit"] = {"analyses": all_analyses, "report": report}
|
| 817 |
|
| 818 |
yield {"event": "complete", "data": json.dumps({
|
web/static/index.html
CHANGED
|
@@ -594,6 +594,28 @@ const TEMPLATES = {
|
|
| 594 |
let sessionId = null;
|
| 595 |
let evalResultsData = null;
|
| 596 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
// XSS sanitization helper
|
| 598 |
function esc(str) {
|
| 599 |
if (str == null) return '';
|
|
@@ -640,27 +662,17 @@ async function init() {
|
|
| 640 |
// Changes are auto-generated from evaluation concerns
|
| 641 |
}
|
| 642 |
|
| 643 |
-
|
| 644 |
const key = document.getElementById('apiKeyInput').value.trim();
|
| 645 |
if (!key) return alert('Enter your API key.');
|
| 646 |
-
const baseUrl = document.getElementById('apiBaseUrl').value.trim();
|
| 647 |
-
const model = document.getElementById('apiModel').value.trim();
|
| 648 |
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
headers: {'Content-Type': 'application/json'},
|
| 653 |
-
body: JSON.stringify({api_key: key, base_url: baseUrl, model: model}),
|
| 654 |
-
});
|
| 655 |
-
const data = await resp.json();
|
| 656 |
-
if (!resp.ok) throw new Error(data.detail || 'Failed');
|
| 657 |
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
} catch (e) {
|
| 662 |
-
alert('Failed to set API key: ' + e.message);
|
| 663 |
-
}
|
| 664 |
}
|
| 665 |
|
| 666 |
async function setupNemotron() {
|
|
@@ -762,7 +774,7 @@ async function inferSpec() {
|
|
| 762 |
if (goalField.value.trim() && audienceField.value.trim()) return;
|
| 763 |
|
| 764 |
try {
|
| 765 |
-
const resp = await fetch('/api/infer-spec', {
|
| 766 |
method: 'POST',
|
| 767 |
headers: {'Content-Type': 'application/json'},
|
| 768 |
body: JSON.stringify({entity_text: text}),
|
|
@@ -820,7 +832,7 @@ async function runFullPipeline() {
|
|
| 820 |
document.getElementById('pipelineProgressBar').style.width = '10%';
|
| 821 |
logStep('Choosing the right panel segments...');
|
| 822 |
|
| 823 |
-
const segResp = await fetch('/api/suggest-segments', {
|
| 824 |
method: 'POST',
|
| 825 |
headers: {'Content-Type': 'application/json'},
|
| 826 |
body: JSON.stringify({
|
|
@@ -845,7 +857,7 @@ async function runFullPipeline() {
|
|
| 845 |
logStep('Building panel members for each segment...');
|
| 846 |
|
| 847 |
const desc = audienceCtx || `People evaluating: ${text.substring(0, 200)}`;
|
| 848 |
-
const cohortResp = await fetch('/api/cohort/generate', {
|
| 849 |
method: 'POST',
|
| 850 |
headers: {'Content-Type': 'application/json'},
|
| 851 |
body: JSON.stringify({description: desc, audience_context: audienceCtx, segments, parallel: 3}),
|
|
@@ -882,6 +894,7 @@ async function runFullPipeline() {
|
|
| 882 |
|
| 883 |
await new Promise((resolve, reject) => {
|
| 884 |
const params = new URLSearchParams({parallel: 5, bias_calibration: biasCal});
|
|
|
|
| 885 |
const es = new EventSource(`/api/evaluate/stream/${sessionId}?${params}`);
|
| 886 |
|
| 887 |
es.addEventListener('start', (e) => {
|
|
@@ -974,7 +987,7 @@ async function runDirections() {
|
|
| 974 |
|
| 975 |
// Phase 2: LLM generates candidate changes from concerns
|
| 976 |
document.getElementById('cfProgressText').textContent = 'Proposing changes to test...';
|
| 977 |
-
const suggestResp = await fetch('/api/suggest-changes', {
|
| 978 |
method: 'POST',
|
| 979 |
headers: {'Content-Type': 'application/json'},
|
| 980 |
body: JSON.stringify({entity_text: entityText, goal, concerns}),
|
|
@@ -1006,7 +1019,9 @@ async function runDirections() {
|
|
| 1006 |
const {ticket} = await prepResp.json();
|
| 1007 |
|
| 1008 |
await new Promise((resolve, reject) => {
|
| 1009 |
-
const
|
|
|
|
|
|
|
| 1010 |
|
| 1011 |
es.addEventListener('start', (e) => {
|
| 1012 |
const d = JSON.parse(e.data);
|
|
|
|
| 594 |
let sessionId = null;
|
| 595 |
let evalResultsData = null;
|
| 596 |
|
| 597 |
+
// LLM credentials — stored only in browser JS memory, never persisted
|
| 598 |
+
let llmApiKey = '';
|
| 599 |
+
let llmBaseUrl = '';
|
| 600 |
+
let llmModel = '';
|
| 601 |
+
|
| 602 |
+
function llmHeaders() {
|
| 603 |
+
return {'Content-Type': 'application/json', 'X-LLM-Key': llmApiKey, 'X-LLM-Base': llmBaseUrl, 'X-LLM-Model': llmModel};
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
function llmQueryParams() {
|
| 607 |
+
const p = new URLSearchParams();
|
| 608 |
+
if (llmApiKey) p.set('api_key', llmApiKey);
|
| 609 |
+
if (llmBaseUrl) p.set('base_url', llmBaseUrl);
|
| 610 |
+
if (llmModel) p.set('model', llmModel);
|
| 611 |
+
return p;
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
function apiUrl(path) {
|
| 615 |
+
const q = llmQueryParams().toString();
|
| 616 |
+
return q ? `${path}?${q}` : path;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
// XSS sanitization helper
|
| 620 |
function esc(str) {
|
| 621 |
if (str == null) return '';
|
|
|
|
| 662 |
// Changes are auto-generated from evaluation concerns
|
| 663 |
}
|
| 664 |
|
| 665 |
+
function saveApiKey() {
|
| 666 |
const key = document.getElementById('apiKeyInput').value.trim();
|
| 667 |
if (!key) return alert('Enter your API key.');
|
|
|
|
|
|
|
| 668 |
|
| 669 |
+
llmApiKey = key;
|
| 670 |
+
llmBaseUrl = document.getElementById('apiBaseUrl').value.trim();
|
| 671 |
+
llmModel = document.getElementById('apiModel').value.trim() || 'openai/gpt-4o-mini';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
|
| 673 |
+
document.getElementById('configBadge').textContent = llmModel;
|
| 674 |
+
document.getElementById('configBadge').className = 'config-badge ok';
|
| 675 |
+
document.getElementById('apiKeySetup').classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
| 676 |
}
|
| 677 |
|
| 678 |
async function setupNemotron() {
|
|
|
|
| 774 |
if (goalField.value.trim() && audienceField.value.trim()) return;
|
| 775 |
|
| 776 |
try {
|
| 777 |
+
const resp = await fetch(apiUrl('/api/infer-spec'), {
|
| 778 |
method: 'POST',
|
| 779 |
headers: {'Content-Type': 'application/json'},
|
| 780 |
body: JSON.stringify({entity_text: text}),
|
|
|
|
| 832 |
document.getElementById('pipelineProgressBar').style.width = '10%';
|
| 833 |
logStep('Choosing the right panel segments...');
|
| 834 |
|
| 835 |
+
const segResp = await fetch(apiUrl('/api/suggest-segments'), {
|
| 836 |
method: 'POST',
|
| 837 |
headers: {'Content-Type': 'application/json'},
|
| 838 |
body: JSON.stringify({
|
|
|
|
| 857 |
logStep('Building panel members for each segment...');
|
| 858 |
|
| 859 |
const desc = audienceCtx || `People evaluating: ${text.substring(0, 200)}`;
|
| 860 |
+
const cohortResp = await fetch(apiUrl('/api/cohort/generate'), {
|
| 861 |
method: 'POST',
|
| 862 |
headers: {'Content-Type': 'application/json'},
|
| 863 |
body: JSON.stringify({description: desc, audience_context: audienceCtx, segments, parallel: 3}),
|
|
|
|
| 894 |
|
| 895 |
await new Promise((resolve, reject) => {
|
| 896 |
const params = new URLSearchParams({parallel: 5, bias_calibration: biasCal});
|
| 897 |
+
llmQueryParams().forEach((v, k) => params.set(k, v));
|
| 898 |
const es = new EventSource(`/api/evaluate/stream/${sessionId}?${params}`);
|
| 899 |
|
| 900 |
es.addEventListener('start', (e) => {
|
|
|
|
| 987 |
|
| 988 |
// Phase 2: LLM generates candidate changes from concerns
|
| 989 |
document.getElementById('cfProgressText').textContent = 'Proposing changes to test...';
|
| 990 |
+
const suggestResp = await fetch(apiUrl('/api/suggest-changes'), {
|
| 991 |
method: 'POST',
|
| 992 |
headers: {'Content-Type': 'application/json'},
|
| 993 |
body: JSON.stringify({entity_text: entityText, goal, concerns}),
|
|
|
|
| 1019 |
const {ticket} = await prepResp.json();
|
| 1020 |
|
| 1021 |
await new Promise((resolve, reject) => {
|
| 1022 |
+
const cfParams = new URLSearchParams({ticket});
|
| 1023 |
+
llmQueryParams().forEach((v, k) => cfParams.set(k, v));
|
| 1024 |
+
const es = new EventSource(`/api/counterfactual/stream/${sessionId}?${cfParams}`);
|
| 1025 |
|
| 1026 |
es.addEventListener('start', (e) => {
|
| 1027 |
const d = JSON.parse(e.data);
|