aagparekh commited on
Commit
b0c701c
·
1 Parent(s): 5c277bb

Add interactive frontend UI

Browse files
README.md CHANGED
@@ -48,6 +48,16 @@ The agent can take four actions:
48
 
49
  The environment is intentionally simple to run but hard to master. A weak agent can guess an answer. A stronger agent must notice contradictions and avoid over-flagging clean documents.
50
 
 
 
 
 
 
 
 
 
 
 
51
  ## Reward
52
 
53
  The reward is deterministic and compositional. There is no hidden LLM judge.
 
48
 
49
  The environment is intentionally simple to run but hard to master. A weak agent can guess an answer. A stronger agent must notice contradictions and avoid over-flagging clean documents.
50
 
51
+ ## Interactive Demo UI
52
+
53
+ The FastAPI app serves a lightweight frontend at `/`. It lets users start an episode, inspect the eight retrieved documents, spend read budget, flag suspicious documents, submit an answer with confidence, and optionally call the trained model through `/model/infer`.
54
+
55
+ Run locally with:
56
+
57
+ ```bash
58
+ uvicorn environment.server:app --host 0.0.0.0 --port 7860
59
+ ```
60
+
61
  ## Reward
62
 
63
  The reward is deterministic and compositional. There is no hidden LLM judge.
environment/model_inference.py CHANGED
@@ -1,4 +1,5 @@
1
  import os
 
2
  import threading
3
  from pathlib import Path
4
  from typing import Any
@@ -10,10 +11,13 @@ from environment.actions import EpisodeObservation
10
 
11
  MODEL_ID = os.getenv("MODEL_ID", "Siddh12334/qwen-1.5b-context-corruption")
12
  MAX_NEW_TOKENS = int(os.getenv("MODEL_MAX_NEW_TOKENS", "128"))
 
13
  _LOCK = threading.Lock()
14
  _MODEL = None
15
  _TOKENIZER = None
16
  _CACHE_DIR = None
 
 
17
 
18
 
19
  class InferenceRequest(BaseModel):
@@ -23,6 +27,8 @@ class InferenceRequest(BaseModel):
23
  class InferenceResponse(BaseModel):
24
  text: str
25
  loaded_model: str
 
 
26
 
27
 
28
  def configure_runtime_dirs() -> Path:
@@ -44,20 +50,46 @@ def configure_runtime_dirs() -> Path:
44
 
45
  _CACHE_DIR = configure_runtime_dirs()
46
 
47
- import torch
48
-
49
 
50
  def model_status() -> dict[str, Any]:
 
51
  return {
52
  "model_id": MODEL_ID,
53
  "loaded": _MODEL is not None,
 
 
 
54
  "cuda_available": torch.cuda.is_available(),
55
  "cuda_device": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
56
  }
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def _load_model():
60
- global _MODEL, _TOKENIZER
61
  if _MODEL is not None and _TOKENIZER is not None:
62
  return _MODEL, _TOKENIZER
63
 
@@ -65,6 +97,9 @@ def _load_model():
65
  if _MODEL is not None and _TOKENIZER is not None:
66
  return _MODEL, _TOKENIZER
67
 
 
 
 
68
  from peft import PeftConfig, PeftModel
69
  from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
70
 
@@ -127,7 +162,18 @@ def _format_prompt(observation: EpisodeObservation) -> list[dict[str, str]]:
127
 
128
 
129
  def run_inference(observation: EpisodeObservation) -> InferenceResponse:
 
 
 
 
 
 
 
 
 
 
130
  model, tokenizer = _load_model()
 
131
  messages = _format_prompt(observation)
132
  prompt = tokenizer.apply_chat_template(
133
  messages,
@@ -145,3 +191,76 @@ def run_inference(observation: EpisodeObservation) -> InferenceResponse:
145
  generated_ids = output_ids[0][inputs["input_ids"].shape[-1]:]
146
  text = tokenizer.decode(generated_ids, skip_special_tokens=True).strip()
147
  return InferenceResponse(text=text, loaded_model=MODEL_ID)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ import re
3
  import threading
4
  from pathlib import Path
5
  from typing import Any
 
11
 
12
  MODEL_ID = os.getenv("MODEL_ID", "Siddh12334/qwen-1.5b-context-corruption")
13
  MAX_NEW_TOKENS = int(os.getenv("MODEL_MAX_NEW_TOKENS", "128"))
14
+ ENABLE_TRAINED_MODEL = os.getenv("ENABLE_TRAINED_MODEL", "true").lower() not in {"0", "false", "no"}
15
  _LOCK = threading.Lock()
16
  _MODEL = None
17
  _TOKENIZER = None
18
  _CACHE_DIR = None
19
+ _LOAD_STARTED = False
20
+ _LOAD_ERROR = None
21
 
22
 
23
  class InferenceRequest(BaseModel):
 
27
  class InferenceResponse(BaseModel):
28
  text: str
29
  loaded_model: str
30
+ mode: str = "trained"
31
+ model_ready: bool = True
32
 
33
 
34
  def configure_runtime_dirs() -> Path:
 
50
 
51
  _CACHE_DIR = configure_runtime_dirs()
52
 
 
 
53
 
54
  def model_status() -> dict[str, Any]:
55
+ torch = _import_torch()
56
  return {
57
  "model_id": MODEL_ID,
58
  "loaded": _MODEL is not None,
59
+ "loading": _LOAD_STARTED and _MODEL is None and _LOAD_ERROR is None,
60
+ "load_error": _LOAD_ERROR,
61
+ "enabled": ENABLE_TRAINED_MODEL,
62
  "cuda_available": torch.cuda.is_available(),
63
  "cuda_device": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
64
  }
65
 
66
 
67
+ def _import_torch():
68
+ import torch
69
+
70
+ return torch
71
+
72
+
73
+ def warm_model_async() -> None:
74
+ global _LOAD_STARTED
75
+ if not ENABLE_TRAINED_MODEL or _MODEL is not None or _LOAD_STARTED:
76
+ return
77
+
78
+ _LOAD_STARTED = True
79
+ thread = threading.Thread(target=_load_model_safely, name="model-loader", daemon=True)
80
+ thread.start()
81
+
82
+
83
+ def _load_model_safely():
84
+ global _LOAD_ERROR
85
+ try:
86
+ _load_model()
87
+ except Exception as exc: # pragma: no cover - depends on remote model/runtime.
88
+ _LOAD_ERROR = str(exc)
89
+
90
+
91
  def _load_model():
92
+ global _MODEL, _TOKENIZER, _LOAD_ERROR, _LOAD_STARTED
93
  if _MODEL is not None and _TOKENIZER is not None:
94
  return _MODEL, _TOKENIZER
95
 
 
97
  if _MODEL is not None and _TOKENIZER is not None:
98
  return _MODEL, _TOKENIZER
99
 
100
+ _LOAD_STARTED = True
101
+ _LOAD_ERROR = None
102
+ torch = _import_torch()
103
  from peft import PeftConfig, PeftModel
104
  from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
105
 
 
162
 
163
 
164
  def run_inference(observation: EpisodeObservation) -> InferenceResponse:
165
+ if not ENABLE_TRAINED_MODEL:
166
+ return _fast_inference(observation, "Trained model loading is disabled.")
167
+
168
+ if _MODEL is None or _TOKENIZER is None:
169
+ warm_model_async()
170
+ return _fast_inference(
171
+ observation,
172
+ "Trained model is warming up. Returning a fast heuristic response for the demo.",
173
+ )
174
+
175
  model, tokenizer = _load_model()
176
+ torch = _import_torch()
177
  messages = _format_prompt(observation)
178
  prompt = tokenizer.apply_chat_template(
179
  messages,
 
191
  generated_ids = output_ids[0][inputs["input_ids"].shape[-1]:]
192
  text = tokenizer.decode(generated_ids, skip_special_tokens=True).strip()
193
  return InferenceResponse(text=text, loaded_model=MODEL_ID)
194
+
195
+
196
+ def _fast_inference(observation: EpisodeObservation, note: str) -> InferenceResponse:
197
+ docs = observation.documents
198
+ flagged = set(observation.flagged_ids)
199
+ candidates = []
200
+ for doc in docs:
201
+ if doc.id in flagged:
202
+ continue
203
+ candidate = _extract_candidate_answer(observation.question, doc.content)
204
+ if candidate:
205
+ candidates.append(candidate)
206
+
207
+ answer = _majority_vote(candidates) or "unknown"
208
+ suspicious_docs = sorted(flagged)
209
+ text = (
210
+ "{"
211
+ f'"answer": "{_json_escape(answer)}", '
212
+ f'"suspicious_docs": {suspicious_docs}, '
213
+ '"confidence": 0.55, '
214
+ f'"note": "{_json_escape(note)}"'
215
+ "}"
216
+ )
217
+ return InferenceResponse(
218
+ text=text,
219
+ loaded_model=MODEL_ID,
220
+ mode="heuristic",
221
+ model_ready=False,
222
+ )
223
+
224
+
225
+ def _extract_candidate_answer(question: str, content: str) -> str | None:
226
+ patterns = [
227
+ r"\banswer\s+is\s+([^.;,\n]+)",
228
+ r"\banswer\s+remains\s+([^.;,\n]+)",
229
+ r"\brecords\s+([^.;,\n]+)\s+as\s+the\s+answer",
230
+ r"\bconfirms\s+that\s+([^.;,\n]+)",
231
+ ]
232
+ for pattern in patterns:
233
+ match = re.search(pattern, content, flags=re.IGNORECASE)
234
+ if match:
235
+ return match.group(1).strip(" '\"")
236
+
237
+ quoted = re.findall(r'"([^"]{2,80})"', content)
238
+ if quoted:
239
+ return quoted[-1].strip()
240
+
241
+ # Last resort: use a short proper-noun span that is not just copied from the question.
242
+ question_terms = set(re.findall(r"[A-Z][a-z]+", question))
243
+ spans = re.findall(r"\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3}\b", content)
244
+ for span in reversed(spans):
245
+ if span not in question_terms and len(span.split()) <= 4:
246
+ return span.strip()
247
+ return None
248
+
249
+
250
+ def _majority_vote(candidates: list[str]) -> str | None:
251
+ if not candidates:
252
+ return None
253
+ counts: dict[str, tuple[int, str]] = {}
254
+ for candidate in candidates:
255
+ key = re.sub(r"\W+", " ", candidate).strip().lower()
256
+ if not key:
257
+ continue
258
+ count, original = counts.get(key, (0, candidate))
259
+ counts[key] = (count + 1, original)
260
+ if not counts:
261
+ return candidates[0]
262
+ return max(counts.values(), key=lambda item: item[0])[1]
263
+
264
+
265
+ def _json_escape(value: str) -> str:
266
+ return value.replace("\\", "\\\\").replace('"', '\\"')
environment/server.py CHANGED
@@ -1,5 +1,9 @@
1
  import os
 
 
2
  from dotenv import load_dotenv
 
 
3
  from openenv.core import create_app
4
  import uvicorn
5
 
@@ -12,6 +16,7 @@ from environment.model_inference import InferenceRequest, model_status, run_infe
12
  _difficulty_env = os.getenv("DIFFICULTY")
13
  _difficulty = int(_difficulty_env) if _difficulty_env else None
14
  _max_sessions = int(os.getenv("MAX_CONCURRENT_ENVS", "64"))
 
15
 
16
  app = create_app(
17
  env=lambda: ContextCorruptionEnv(difficulty=_difficulty),
@@ -21,9 +26,20 @@ app = create_app(
21
  max_concurrent_envs=_max_sessions,
22
  )
23
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
- @app.get("/")
26
- def root():
27
  return {
28
  "name": "ContextCorruption-Env",
29
  "status": "running",
 
1
  import os
2
+ from pathlib import Path
3
+
4
  from dotenv import load_dotenv
5
+ from fastapi.responses import FileResponse
6
+ from fastapi.staticfiles import StaticFiles
7
  from openenv.core import create_app
8
  import uvicorn
9
 
 
16
  _difficulty_env = os.getenv("DIFFICULTY")
17
  _difficulty = int(_difficulty_env) if _difficulty_env else None
18
  _max_sessions = int(os.getenv("MAX_CONCURRENT_ENVS", "64"))
19
+ _frontend_dir = Path(__file__).parent.parent / "frontend"
20
 
21
  app = create_app(
22
  env=lambda: ContextCorruptionEnv(difficulty=_difficulty),
 
26
  max_concurrent_envs=_max_sessions,
27
  )
28
 
29
+ if _frontend_dir.exists():
30
+ app.mount("/static", StaticFiles(directory=_frontend_dir), name="static")
31
+
32
+
33
+ @app.get("/", include_in_schema=False)
34
+ def frontend():
35
+ index_path = _frontend_dir / "index.html"
36
+ if index_path.exists():
37
+ return FileResponse(index_path)
38
+ return api_status()
39
+
40
 
41
+ @app.get("/api/status")
42
+ def api_status():
43
  return {
44
  "name": "ContextCorruption-Env",
45
  "status": "running",
frontend/app.js ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const state = {
2
+ observation: null,
3
+ done: false,
4
+ reward: null,
5
+ readIds: new Set(),
6
+ };
7
+
8
+ const els = {
9
+ resetBtn: document.querySelector("#resetBtn"),
10
+ modelBtn: document.querySelector("#modelBtn"),
11
+ submitBtn: document.querySelector("#submitBtn"),
12
+ status: document.querySelector("#status"),
13
+ questionText: document.querySelector("#questionText"),
14
+ documents: document.querySelector("#documents"),
15
+ budget: document.querySelector("#budget"),
16
+ turn: document.querySelector("#turn"),
17
+ flags: document.querySelector("#flags"),
18
+ answerInput: document.querySelector("#answerInput"),
19
+ confidenceInput: document.querySelector("#confidenceInput"),
20
+ confidenceValue: document.querySelector("#confidenceValue"),
21
+ result: document.querySelector("#result"),
22
+ modelOutput: document.querySelector("#modelOutput"),
23
+ };
24
+
25
+ function setStatus(message, isError = false) {
26
+ els.status.textContent = message;
27
+ els.status.style.color = isError ? "var(--danger)" : "var(--muted)";
28
+ }
29
+
30
+ async function api(path, options = {}) {
31
+ const response = await fetch(path, {
32
+ headers: { "Content-Type": "application/json", ...(options.headers || {}) },
33
+ ...options,
34
+ });
35
+
36
+ if (!response.ok) {
37
+ const text = await response.text();
38
+ throw new Error(text || `${response.status} ${response.statusText}`);
39
+ }
40
+
41
+ return response.json();
42
+ }
43
+
44
+ function unwrap(response) {
45
+ state.observation = response.observation;
46
+ state.done = Boolean(response.done ?? response.observation?.done);
47
+ state.reward = response.reward ?? response.observation?.reward ?? null;
48
+ render();
49
+ }
50
+
51
+ async function resetEpisode() {
52
+ setStatus("Starting new episode...");
53
+ els.resetBtn.disabled = true;
54
+ state.readIds.clear();
55
+ state.reward = null;
56
+ els.answerInput.value = "";
57
+ els.result.classList.add("hidden");
58
+ els.modelOutput.classList.add("hidden");
59
+
60
+ try {
61
+ unwrap(await api("/reset", { method: "POST", body: JSON.stringify({}) }));
62
+ setStatus("Episode loaded");
63
+ } catch (error) {
64
+ setStatus(`Reset failed: ${error.message}`, true);
65
+ } finally {
66
+ els.resetBtn.disabled = false;
67
+ }
68
+ }
69
+
70
+ async function step(action) {
71
+ if (!state.observation || state.done) return;
72
+ setStatus(`Sending ${action.action_type}...`);
73
+
74
+ try {
75
+ const response = await api("/step", {
76
+ method: "POST",
77
+ body: JSON.stringify({ action }),
78
+ });
79
+ unwrap(response);
80
+ setStatus(response.done ? "Episode complete" : "Action applied");
81
+ } catch (error) {
82
+ setStatus(`Action failed: ${error.message}`, true);
83
+ }
84
+ }
85
+
86
+ async function readDoc(docId) {
87
+ state.readIds.add(docId);
88
+ await step({ action_type: "read_doc", doc_id: docId });
89
+ }
90
+
91
+ async function toggleFlag(doc) {
92
+ const flagged = state.observation.flagged_ids.includes(doc.id);
93
+ await step({
94
+ action_type: flagged ? "unflag_doc" : "flag_suspicious",
95
+ doc_id: doc.id,
96
+ });
97
+ }
98
+
99
+ async function submitAnswer() {
100
+ if (!state.observation || state.done) return;
101
+ await step({
102
+ action_type: "submit_answer",
103
+ answer: els.answerInput.value.trim(),
104
+ confidence: Number(els.confidenceInput.value),
105
+ });
106
+ }
107
+
108
+ async function askModel() {
109
+ if (!state.observation) return;
110
+
111
+ els.modelBtn.disabled = true;
112
+ els.modelOutput.classList.remove("hidden");
113
+ els.modelOutput.textContent = "Loading trained model response. This may take a while on cold start...";
114
+ setStatus("Calling trained model...");
115
+
116
+ try {
117
+ const response = await api("/model/infer", {
118
+ method: "POST",
119
+ body: JSON.stringify({ observation: state.observation }),
120
+ });
121
+ const label = response.model_ready ? "Trained model output" : "Fast response while model warms up";
122
+ els.modelOutput.textContent = `${label}:\n${response.text}`;
123
+ setStatus(response.model_ready ? "Model response ready" : "Model warming in background");
124
+ } catch (error) {
125
+ els.modelOutput.textContent = `Model inference failed:\n${error.message}`;
126
+ setStatus("Model inference failed", true);
127
+ } finally {
128
+ els.modelBtn.disabled = false;
129
+ }
130
+ }
131
+
132
+ function render() {
133
+ const obs = state.observation;
134
+ const hasEpisode = Boolean(obs);
135
+
136
+ els.questionText.textContent = obs?.question || "Start a new episode to load a question.";
137
+ els.budget.textContent = obs?.budget_remaining ?? 12;
138
+ els.turn.textContent = obs?.turn ?? 0;
139
+ els.flags.textContent = obs?.flagged_ids?.length ?? 0;
140
+ els.submitBtn.disabled = !hasEpisode || state.done;
141
+ els.modelBtn.disabled = !hasEpisode;
142
+
143
+ renderDocuments(obs?.documents || []);
144
+ renderResult();
145
+ }
146
+
147
+ function renderDocuments(documents) {
148
+ els.documents.innerHTML = "";
149
+
150
+ if (!documents.length) {
151
+ els.documents.innerHTML = '<p class="hint">No documents loaded yet.</p>';
152
+ return;
153
+ }
154
+
155
+ for (const doc of documents) {
156
+ const flagged = state.observation.flagged_ids.includes(doc.id);
157
+ const read = state.readIds.has(doc.id);
158
+ const card = document.createElement("article");
159
+ card.className = `doc-card${flagged ? " flagged" : ""}${read ? " read" : ""}`;
160
+ card.innerHTML = `
161
+ <div class="doc-top">
162
+ <p class="doc-title">${escapeHtml(doc.title)}</p>
163
+ <span class="badge">Doc ${doc.id}</span>
164
+ </div>
165
+ <p class="doc-content">${escapeHtml(doc.content)}</p>
166
+ <div class="doc-actions">
167
+ <button class="secondary" type="button" data-action="read" data-doc-id="${doc.id}">${read ? "Read Again" : "Read"}</button>
168
+ <button type="button" data-action="flag" data-doc-id="${doc.id}">${flagged ? "Unflag" : "Flag"}</button>
169
+ </div>
170
+ `;
171
+ els.documents.appendChild(card);
172
+ }
173
+ }
174
+
175
+ function renderResult() {
176
+ if (!state.done) {
177
+ els.result.classList.add("hidden");
178
+ return;
179
+ }
180
+
181
+ els.result.classList.remove("hidden");
182
+ els.result.innerHTML = `
183
+ <strong>Episode complete.</strong>
184
+ <br />Reward: <strong>${state.reward ?? "n/a"}</strong>
185
+ <br />Flagged documents: ${state.observation.flagged_ids.join(", ") || "none"}
186
+ <br />Budget remaining: ${state.observation.budget_remaining}
187
+ `;
188
+ }
189
+
190
+ function escapeHtml(value) {
191
+ return String(value)
192
+ .replaceAll("&", "&amp;")
193
+ .replaceAll("<", "&lt;")
194
+ .replaceAll(">", "&gt;")
195
+ .replaceAll('"', "&quot;")
196
+ .replaceAll("'", "&#039;");
197
+ }
198
+
199
+ els.resetBtn.addEventListener("click", resetEpisode);
200
+ els.submitBtn.addEventListener("click", submitAnswer);
201
+ els.modelBtn.addEventListener("click", askModel);
202
+ els.confidenceInput.addEventListener("input", () => {
203
+ els.confidenceValue.textContent = Number(els.confidenceInput.value).toFixed(2);
204
+ });
205
+ els.documents.addEventListener("click", (event) => {
206
+ const button = event.target.closest("button[data-action]");
207
+ if (!button || state.done) return;
208
+
209
+ const docId = Number(button.dataset.docId);
210
+ const doc = state.observation.documents.find((item) => item.id === docId);
211
+ if (!doc) return;
212
+
213
+ if (button.dataset.action === "read") {
214
+ readDoc(docId);
215
+ } else {
216
+ toggleFlag(doc);
217
+ }
218
+ });
219
+
220
+ render();
221
+ resetEpisode();
frontend/index.html ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ContextCorruption-Env</title>
7
+ <link rel="stylesheet" href="/static/styles.css" />
8
+ </head>
9
+ <body>
10
+ <main class="shell">
11
+ <section class="hero">
12
+ <div>
13
+ <p class="eyebrow">OpenEnv Demo</p>
14
+ <h1>ContextCorruption-Env</h1>
15
+ <p class="lede">
16
+ Explore a multi-document QA episode where some retrieved evidence is corrupted.
17
+ Flag suspicious documents, submit an answer, and inspect the deterministic reward.
18
+ </p>
19
+ </div>
20
+ <div class="hero-card">
21
+ <div class="stat">
22
+ <span id="budget">12</span>
23
+ <label>Budget Left</label>
24
+ </div>
25
+ <div class="stat">
26
+ <span id="turn">0</span>
27
+ <label>Turns Used</label>
28
+ </div>
29
+ <div class="stat">
30
+ <span id="flags">0</span>
31
+ <label>Docs Flagged</label>
32
+ </div>
33
+ </div>
34
+ </section>
35
+
36
+ <section class="toolbar panel">
37
+ <button id="resetBtn" type="button">New Episode</button>
38
+ <button id="modelBtn" class="secondary" type="button">Ask Trained Model</button>
39
+ <a class="link-button" href="/docs" target="_blank" rel="noreferrer">API Docs</a>
40
+ <span id="status" class="status">Ready</span>
41
+ </section>
42
+
43
+ <section class="question panel">
44
+ <p class="section-label">Question</p>
45
+ <h2 id="questionText">Start a new episode to load a question.</h2>
46
+ </section>
47
+
48
+ <section class="workspace">
49
+ <div class="panel">
50
+ <div class="panel-heading">
51
+ <div>
52
+ <p class="section-label">Retrieved Documents</p>
53
+ <h2>Evidence Board</h2>
54
+ </div>
55
+ <p class="hint">Use read to spend budget; flag documents that disagree or look fabricated.</p>
56
+ </div>
57
+ <div id="documents" class="documents"></div>
58
+ </div>
59
+
60
+ <aside class="panel answer-panel">
61
+ <p class="section-label">Final Answer</p>
62
+ <label for="answerInput">Answer</label>
63
+ <textarea id="answerInput" rows="4" placeholder="Type the answer you believe is correct..."></textarea>
64
+
65
+ <label for="confidenceInput">Confidence: <span id="confidenceValue">0.80</span></label>
66
+ <input id="confidenceInput" type="range" min="0" max="1" step="0.05" value="0.8" />
67
+
68
+ <button id="submitBtn" type="button">Submit Answer</button>
69
+
70
+ <div id="result" class="result hidden"></div>
71
+ <div id="modelOutput" class="model-output hidden"></div>
72
+ </aside>
73
+ </section>
74
+ </main>
75
+
76
+ <script src="/static/app.js"></script>
77
+ </body>
78
+ </html>
frontend/styles.css ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #07111f;
4
+ --panel: rgba(17, 31, 52, 0.86);
5
+ --panel-strong: rgba(27, 45, 73, 0.94);
6
+ --text: #ecf4ff;
7
+ --muted: #a6b7cc;
8
+ --line: rgba(168, 198, 255, 0.18);
9
+ --accent: #7dd3fc;
10
+ --accent-strong: #38bdf8;
11
+ --danger: #fb7185;
12
+ --success: #34d399;
13
+ --warning: #fbbf24;
14
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15
+ }
16
+
17
+ * {
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ body {
22
+ min-height: 100vh;
23
+ margin: 0;
24
+ background:
25
+ radial-gradient(circle at top left, rgba(56, 189, 248, 0.22), transparent 32rem),
26
+ radial-gradient(circle at 80% 10%, rgba(129, 140, 248, 0.18), transparent 28rem),
27
+ var(--bg);
28
+ color: var(--text);
29
+ }
30
+
31
+ button,
32
+ .link-button {
33
+ border: 0;
34
+ border-radius: 999px;
35
+ background: linear-gradient(135deg, var(--accent), var(--accent-strong));
36
+ color: #04111f;
37
+ cursor: pointer;
38
+ display: inline-flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ font-weight: 750;
42
+ min-height: 2.6rem;
43
+ padding: 0.75rem 1rem;
44
+ text-decoration: none;
45
+ }
46
+
47
+ button:disabled {
48
+ cursor: not-allowed;
49
+ filter: grayscale(0.5);
50
+ opacity: 0.55;
51
+ }
52
+
53
+ button.secondary,
54
+ .link-button {
55
+ background: rgba(125, 211, 252, 0.1);
56
+ border: 1px solid rgba(125, 211, 252, 0.34);
57
+ color: var(--accent);
58
+ }
59
+
60
+ textarea,
61
+ input[type="range"] {
62
+ width: 100%;
63
+ }
64
+
65
+ textarea {
66
+ background: rgba(7, 17, 31, 0.72);
67
+ border: 1px solid var(--line);
68
+ border-radius: 1rem;
69
+ color: var(--text);
70
+ font: inherit;
71
+ padding: 0.85rem 1rem;
72
+ resize: vertical;
73
+ }
74
+
75
+ label {
76
+ color: var(--muted);
77
+ display: block;
78
+ font-size: 0.9rem;
79
+ font-weight: 650;
80
+ margin: 1rem 0 0.45rem;
81
+ }
82
+
83
+ .shell {
84
+ margin: 0 auto;
85
+ max-width: 1240px;
86
+ padding: 2rem;
87
+ }
88
+
89
+ .hero {
90
+ align-items: stretch;
91
+ display: grid;
92
+ gap: 1rem;
93
+ grid-template-columns: minmax(0, 1fr) minmax(18rem, 28rem);
94
+ margin-bottom: 1rem;
95
+ }
96
+
97
+ .hero h1 {
98
+ font-size: clamp(2.4rem, 6vw, 5rem);
99
+ letter-spacing: -0.06em;
100
+ line-height: 0.92;
101
+ margin: 0;
102
+ }
103
+
104
+ .lede {
105
+ color: var(--muted);
106
+ font-size: 1.1rem;
107
+ line-height: 1.65;
108
+ max-width: 56rem;
109
+ }
110
+
111
+ .eyebrow,
112
+ .section-label {
113
+ color: var(--accent);
114
+ font-size: 0.78rem;
115
+ font-weight: 800;
116
+ letter-spacing: 0.13em;
117
+ margin: 0 0 0.65rem;
118
+ text-transform: uppercase;
119
+ }
120
+
121
+ .panel,
122
+ .hero-card {
123
+ backdrop-filter: blur(18px);
124
+ background: var(--panel);
125
+ border: 1px solid var(--line);
126
+ border-radius: 1.4rem;
127
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.26);
128
+ }
129
+
130
+ .hero-card {
131
+ display: grid;
132
+ gap: 1px;
133
+ grid-template-columns: repeat(3, 1fr);
134
+ overflow: hidden;
135
+ }
136
+
137
+ .stat {
138
+ background: rgba(255, 255, 255, 0.03);
139
+ display: grid;
140
+ place-content: center;
141
+ padding: 1.2rem;
142
+ text-align: center;
143
+ }
144
+
145
+ .stat span {
146
+ font-size: 2rem;
147
+ font-weight: 850;
148
+ }
149
+
150
+ .stat label {
151
+ margin: 0.25rem 0 0;
152
+ }
153
+
154
+ .toolbar {
155
+ align-items: center;
156
+ display: flex;
157
+ flex-wrap: wrap;
158
+ gap: 0.75rem;
159
+ margin-bottom: 1rem;
160
+ padding: 1rem;
161
+ }
162
+
163
+ .status {
164
+ color: var(--muted);
165
+ margin-left: auto;
166
+ }
167
+
168
+ .question {
169
+ margin-bottom: 1rem;
170
+ padding: 1.35rem;
171
+ }
172
+
173
+ .question h2 {
174
+ font-size: clamp(1.35rem, 3vw, 2.15rem);
175
+ letter-spacing: -0.035em;
176
+ margin: 0;
177
+ }
178
+
179
+ .workspace {
180
+ align-items: start;
181
+ display: grid;
182
+ gap: 1rem;
183
+ grid-template-columns: minmax(0, 1fr) minmax(19rem, 25rem);
184
+ }
185
+
186
+ .workspace > .panel,
187
+ .answer-panel {
188
+ padding: 1.25rem;
189
+ }
190
+
191
+ .panel-heading {
192
+ align-items: end;
193
+ display: flex;
194
+ gap: 1rem;
195
+ justify-content: space-between;
196
+ margin-bottom: 1rem;
197
+ }
198
+
199
+ .panel-heading h2 {
200
+ margin: 0;
201
+ }
202
+
203
+ .hint {
204
+ color: var(--muted);
205
+ font-size: 0.9rem;
206
+ line-height: 1.45;
207
+ margin: 0;
208
+ max-width: 24rem;
209
+ }
210
+
211
+ .documents {
212
+ display: grid;
213
+ gap: 0.85rem;
214
+ grid-template-columns: repeat(2, minmax(0, 1fr));
215
+ }
216
+
217
+ .doc-card {
218
+ background: rgba(7, 17, 31, 0.56);
219
+ border: 1px solid var(--line);
220
+ border-radius: 1rem;
221
+ display: flex;
222
+ flex-direction: column;
223
+ gap: 0.8rem;
224
+ min-height: 17rem;
225
+ padding: 1rem;
226
+ }
227
+
228
+ .doc-card.flagged {
229
+ border-color: rgba(251, 113, 133, 0.72);
230
+ box-shadow: inset 0 0 0 1px rgba(251, 113, 133, 0.25);
231
+ }
232
+
233
+ .doc-card.read {
234
+ border-color: rgba(52, 211, 153, 0.46);
235
+ }
236
+
237
+ .doc-top {
238
+ align-items: start;
239
+ display: flex;
240
+ gap: 0.75rem;
241
+ justify-content: space-between;
242
+ }
243
+
244
+ .doc-title {
245
+ font-weight: 800;
246
+ margin: 0;
247
+ }
248
+
249
+ .badge {
250
+ border-radius: 999px;
251
+ color: var(--accent);
252
+ flex: 0 0 auto;
253
+ font-size: 0.78rem;
254
+ font-weight: 800;
255
+ padding: 0.25rem 0.55rem;
256
+ background: rgba(125, 211, 252, 0.12);
257
+ }
258
+
259
+ .doc-content {
260
+ color: var(--muted);
261
+ line-height: 1.55;
262
+ margin: 0;
263
+ }
264
+
265
+ .doc-actions {
266
+ display: flex;
267
+ gap: 0.5rem;
268
+ margin-top: auto;
269
+ }
270
+
271
+ .doc-actions button {
272
+ flex: 1;
273
+ min-height: 2.25rem;
274
+ padding: 0.5rem 0.7rem;
275
+ }
276
+
277
+ .result,
278
+ .model-output {
279
+ background: rgba(7, 17, 31, 0.68);
280
+ border: 1px solid var(--line);
281
+ border-radius: 1rem;
282
+ color: var(--muted);
283
+ line-height: 1.5;
284
+ margin-top: 1rem;
285
+ padding: 1rem;
286
+ white-space: pre-wrap;
287
+ }
288
+
289
+ .result strong {
290
+ color: var(--text);
291
+ }
292
+
293
+ .hidden {
294
+ display: none;
295
+ }
296
+
297
+ @media (max-width: 920px) {
298
+ .hero,
299
+ .workspace {
300
+ grid-template-columns: 1fr;
301
+ }
302
+
303
+ .documents {
304
+ grid-template-columns: 1fr;
305
+ }
306
+
307
+ .status {
308
+ margin-left: 0;
309
+ width: 100%;
310
+ }
311
+ }
312
+
313
+ @media (max-width: 560px) {
314
+ .shell {
315
+ padding: 1rem;
316
+ }
317
+
318
+ .hero-card {
319
+ grid-template-columns: 1fr;
320
+ }
321
+ }