Pandago commited on
Commit
8e495bb
·
verified ·
1 Parent(s): 50f71a7

Upload folder using huggingface_hub

Browse files
Files changed (1) hide show
  1. server/app.py +83 -214
server/app.py CHANGED
@@ -3,25 +3,24 @@
3
  from __future__ import annotations
4
 
5
  import json
 
6
  import sys
7
- import traceback
8
  from pathlib import Path
9
 
10
- # Allow importing sibling modules when running from server/ dir
11
  sys.path.insert(0, str(Path(__file__).parent))
12
  sys.path.insert(0, str(Path(__file__).parent.parent))
13
 
14
  from fastapi import FastAPI, HTTPException
15
- from fastapi.responses import HTMLResponse
16
  from fastapi.middleware.cors import CORSMiddleware
17
  from pydantic import BaseModel
18
- from typing import Any, Dict, List, Optional
19
 
20
  from models import FakeGangAction, FakeGangObservation, FakeGangState, ActionType
21
  from environment import FakeGangEnvironment
22
 
23
  # ---------------------------------------------------------------------------
24
- # App setup
25
  # ---------------------------------------------------------------------------
26
 
27
  app = FastAPI(
@@ -32,16 +31,14 @@ app = FastAPI(
32
 
33
  app.add_middleware(
34
  CORSMiddleware,
35
- allow_origins=["*"],
36
- allow_methods=["*"],
37
- allow_headers=["*"],
38
  )
39
 
40
  _env = FakeGangEnvironment()
41
 
42
 
43
  # ---------------------------------------------------------------------------
44
- # Request/response schemas
45
  # ---------------------------------------------------------------------------
46
 
47
  class ResetRequest(BaseModel):
@@ -49,7 +46,6 @@ class ResetRequest(BaseModel):
49
  seed: Optional[int] = None
50
  episode_id: Optional[str] = None
51
 
52
-
53
  class StepResponse(BaseModel):
54
  observation: Dict[str, Any]
55
  done: bool
@@ -58,43 +54,29 @@ class StepResponse(BaseModel):
58
 
59
 
60
  # ---------------------------------------------------------------------------
61
- # API Endpoints
62
  # ---------------------------------------------------------------------------
63
 
64
  @app.get("/health")
65
- def health() -> Dict[str, str]:
66
  return {"status": "healthy"}
67
 
68
-
69
  @app.post("/reset", response_model=StepResponse)
70
- def reset(req: ResetRequest) -> StepResponse:
71
  obs = _env.reset(task=req.task, seed=req.seed, episode_id=req.episode_id)
72
- return StepResponse(
73
- observation=obs.model_dump(),
74
- done=obs.done,
75
- reward=obs.reward,
76
- message=obs.message,
77
- )
78
-
79
 
80
  @app.post("/step", response_model=StepResponse)
81
- def step(action: FakeGangAction) -> StepResponse:
82
  obs = _env.step(action)
83
- return StepResponse(
84
- observation=obs.model_dump(),
85
- done=obs.done,
86
- reward=obs.reward,
87
- message=obs.message,
88
- )
89
-
90
 
91
  @app.get("/state")
92
- def state() -> Dict[str, Any]:
93
  return _env.state.model_dump()
94
 
95
-
96
  @app.get("/tasks")
97
- def list_tasks() -> Dict[str, Any]:
98
  return {
99
  "tasks": ["easy", "medium", "hard"],
100
  "descriptions": {
@@ -109,159 +91,90 @@ def list_tasks() -> Dict[str, Any]:
109
  "score_range": [0.0, 1.0],
110
  }
111
 
112
-
113
  @app.get("/grader")
114
- def grader() -> Dict[str, Any]:
115
  if not _env._done:
116
  raise HTTPException(status_code=400, detail="Episode not complete. Call SUBMIT first.")
117
- return {
118
- "score": _env._last_grader_score,
119
- "task": _env._task,
120
- "episode_id": _env._episode_id,
121
- }
122
-
123
 
124
  @app.get("/metadata")
125
- def metadata() -> Dict[str, Any]:
126
  return {
127
  "name": "graphstrike",
128
- "description": (
129
- "RL environment for detecting coordinated fake account rings in social networks. "
130
- "An AI agent must identify all 10 members of a coordinated fake account ring "
131
- "hidden inside a synthetic social network before they evade detection."
132
- ),
133
- "version": "1.0.0",
134
- "author": "Pandago",
135
- "tags": ["social-network", "fraud-detection", "graph", "rl", "fake-account-detection"],
136
  }
137
 
138
-
139
  @app.get("/schema")
140
- def schema() -> Dict[str, Any]:
141
  return {
142
  "action": FakeGangAction.model_json_schema(),
143
  "observation": FakeGangObservation.model_json_schema(),
144
  "state": FakeGangState.model_json_schema(),
145
  }
146
 
147
-
148
  @app.post("/mcp")
149
- def mcp(body: Dict[str, Any] = {}) -> Dict[str, Any]:
150
- """Minimal MCP (Model Context Protocol) JSON-RPC 2.0 endpoint."""
151
  method = body.get("method", "")
152
  req_id = body.get("id", 1)
153
-
154
  if method == "tools/list":
155
- return {
156
- "jsonrpc": "2.0",
157
- "id": req_id,
158
- "result": {
159
- "tools": [
160
- {
161
- "name": "reset",
162
- "description": "Reset the environment with a task and seed",
163
- "inputSchema": {
164
- "type": "object",
165
- "properties": {
166
- "task": {"type": "string", "enum": ["easy", "medium", "hard"]},
167
- "seed": {"type": "integer"},
168
- },
169
- },
170
- },
171
- {
172
- "name": "step",
173
- "description": "Take an action in the environment",
174
- "inputSchema": FakeGangAction.model_json_schema(),
175
- },
176
- {
177
- "name": "state",
178
- "description": "Get current episode state",
179
- "inputSchema": {"type": "object", "properties": {}},
180
- },
181
- ]
182
- },
183
- }
184
-
185
- return {
186
- "jsonrpc": "2.0",
187
- "id": req_id,
188
- "result": {
189
- "name": "graphstrike",
190
- "version": "1.0.0",
191
- "protocolVersion": "2024-11-05",
192
- },
193
- }
194
-
195
 
196
  @app.post("/baseline")
197
- def baseline() -> Dict[str, Any]:
198
  sys.path.insert(0, str(Path(__file__).parent.parent))
199
- from inference import run_rule_based_episode # type: ignore[import]
200
- scores: Dict[str, float] = {}
201
  for task in ["easy", "medium", "hard"]:
202
  scores[task] = run_rule_based_episode(_env, task=task, seed=0)
203
  return {"scores": scores, "agent": "rule_based"}
204
 
205
 
206
  # ---------------------------------------------------------------------------
207
- # Gradio web interface
208
  # ---------------------------------------------------------------------------
209
 
210
- _gradio_mounted = False
211
-
212
  try:
213
  import gradio as gr
214
- print("[GraphStrike] Gradio found, building web UI...", flush=True)
215
-
216
- _demo_env = _env
217
 
218
- def _fmt_obs(obs_dict: dict) -> str:
219
  lines = []
220
- lines.append(
221
- f"**Task:** {obs_dict.get('task', '?')} | "
222
- f"**Done:** {obs_dict.get('done', False)} | "
223
- f"**Steps remaining:** {obs_dict.get('steps_remaining', '?')}"
224
- )
225
- if obs_dict.get('reward') is not None:
226
- lines.append(f"**Reward:** {obs_dict['reward']:.2f}")
227
- lines.append(f"**Flagged ({len(obs_dict.get('flagged_ids', []))}/ 10):** {obs_dict.get('flagged_ids', [])}")
228
- suspects = obs_dict.get('suspect_ids', [])
229
- lines.append(f"**Suspects ({len(suspects)}):** {suspects}")
230
- lines.append(f"**Visible accounts:** {len(obs_dict.get('visible_account_ids', []))} IDs")
231
- lines.append(f"**Inspected:** {len(obs_dict.get('inspected_ids', []))} accounts")
232
- if obs_dict.get('evasion_triggered'):
233
- lines.append(f"**Evasion events fired:** {obs_dict.get('evasion_count', 0)}")
234
- lines.append(f"**Message:** {obs_dict.get('message', '')}")
235
  return "\n\n".join(lines)
236
 
237
- def _fmt_profiles(obs_dict: dict) -> str:
238
- accounts = obs_dict.get("visible_accounts", [])
239
- if not accounts:
240
- return "No accounts inspected yet. Use **INSPECT** on an account to reveal its profile."
241
- rows = []
242
- rows.append("| Account | Status | Risk | Node | Beh. | Graph | Hub | Photo | Bio | F.Nbrs |")
243
- rows.append("|---------|--------|------|------|------|-------|-----|-------|-----|--------|")
244
- sorted_accs = sorted(accounts, key=lambda a: a.get("fake_risk_score", 0), reverse=True)
245
- for a in sorted_accs[:25]:
246
- rows.append(
247
- f"| {a.get('account_id','')} "
248
- f"| {a.get('status','?')} "
249
- f"| {a.get('fake_risk_score',0):.3f} "
250
- f"| {a.get('node_risk',0):.2f} "
251
- f"| {a.get('behavior_risk',0):.2f} "
252
- f"| {a.get('graph_risk',0):.2f} "
253
- f"| {a.get('hub_legitimacy_score',0):.2f} "
254
- f"| {a.get('photo_reuse_score',0):.2f} "
255
- f"| {a.get('bio_template_score',0):.2f} "
256
- f"| {a.get('flagged_neighbor_count',0)} |"
257
- )
258
- if len(accounts) > 25:
259
- rows.append(f"| ... +{len(accounts) - 25} more | | | | | | | | | |")
260
  return "\n".join(rows)
261
 
262
  def gr_reset(task, seed):
263
  try:
264
- obs = _demo_env.reset(task=task, seed=int(seed))
265
  d = obs.model_dump()
266
  return _fmt_obs(d), _fmt_profiles(d), json.dumps(d, indent=2, default=str)
267
  except Exception as e:
@@ -271,27 +184,21 @@ try:
271
  try:
272
  acc = account_id.strip() if action_type != "submit" else None
273
  action = FakeGangAction(action_type=ActionType(action_type), account_id=acc)
274
- obs = _demo_env.step(action)
275
  d = obs.model_dump()
276
  return _fmt_obs(d), _fmt_profiles(d), json.dumps(d, indent=2, default=str)
277
  except Exception as e:
278
  return f"**Error:** {e}", "", "{}"
279
 
280
  def gr_grader():
281
- if not _demo_env._done:
282
  return "Episode not complete. Call SUBMIT first."
283
- return json.dumps({
284
- "score": _demo_env._last_grader_score,
285
- "task": _demo_env._task,
286
- "episode_id": _demo_env._episode_id,
287
- }, indent=2)
288
 
289
  def gr_baseline():
290
  sys.path.insert(0, str(Path(__file__).parent.parent))
291
  from inference import run_rule_based_episode
292
- scores = {}
293
- for t in ["easy", "medium", "hard"]:
294
- scores[t] = run_rule_based_episode(_demo_env, task=t, seed=0)
295
  return json.dumps({"scores": scores, "agent": "rule_based"}, indent=2)
296
 
297
  with gr.Blocks(title="GraphStrike") as demo:
@@ -300,99 +207,61 @@ try:
300
  "### Coordinated Fake Account Ring Detection — OpenEnv RL Environment\n\n"
301
  "Detect all 10 members of a coordinated fake account ring hidden in a social network.\n"
302
  "Use **INSPECT** to reveal profiles, **FLAG** to mark fakes, **SUBMIT** to end.\n\n"
303
- "**API endpoints:** `/reset`, `/step`, `/state`, `/grader`, `/baseline`, `/tasks`, `/health`"
304
  )
305
-
306
  with gr.Row():
307
- with gr.Column(scale=1):
308
  gr.Markdown("#### 1. Start Episode")
309
- task_dd = gr.Dropdown(["easy", "medium", "hard"], value="easy", label="Task")
310
- seed_input = gr.Number(value=0, label="Seed", precision=0)
311
  reset_btn = gr.Button("Reset Episode", variant="primary", size="lg")
312
-
313
- with gr.Column(scale=1):
314
  gr.Markdown("#### 2. Take Actions")
315
- action_dd = gr.Dropdown(
316
- ["inspect", "investigate_network", "flag", "unflag", "submit"],
317
- value="inspect", label="Action Type"
318
- )
319
- acc_input = gr.Textbox(label="Account ID", placeholder="e.g. acc_0012")
320
  step_btn = gr.Button("Step", variant="primary", size="lg")
321
 
322
- obs_summary = gr.Markdown(value="*Click 'Reset Episode' to begin.*")
323
-
324
  with gr.Accordion("Account Profiles (sorted by risk)", open=True):
325
- profiles_md = gr.Markdown(value="")
326
-
327
  with gr.Row():
328
  grader_btn = gr.Button("Get Grader Score")
329
  baseline_btn = gr.Button("Run Baseline (all 3 tasks)")
330
  result_box = gr.Textbox(label="Result", lines=5, interactive=False)
331
-
332
- with gr.Accordion("Raw JSON Observation", open=False):
333
  raw_json = gr.Textbox(label="Raw JSON", lines=15, interactive=False)
334
 
335
- reset_btn.click(gr_reset, [task_dd, seed_input], [obs_summary, profiles_md, raw_json])
336
- step_btn.click(gr_step, [action_dd, acc_input], [obs_summary, profiles_md, raw_json])
337
  grader_btn.click(gr_grader, [], result_box)
338
  baseline_btn.click(gr_baseline, [], result_box)
339
 
340
  app = gr.mount_gradio_app(app, demo, path="/")
341
- _gradio_mounted = True
342
  print("[GraphStrike] Gradio UI mounted at /", flush=True)
343
 
344
  except Exception as exc:
345
- print(f"[GraphStrike] Gradio not available ({exc}), serving HTML fallback", flush=True)
346
- traceback.print_exc()
347
 
 
 
 
348
 
349
- # ---------------------------------------------------------------------------
350
- # Fallback: if Gradio didn't load, serve a simple HTML page at /
351
- # ---------------------------------------------------------------------------
352
 
353
- if not _gradio_mounted:
354
- @app.get("/", response_class=HTMLResponse)
355
- def root_html():
356
- return """<!DOCTYPE html>
357
- <html><head><title>GraphStrike</title>
358
- <style>body{font-family:system-ui;max-width:800px;margin:40px auto;padding:0 20px;background:#0d1117;color:#c9d1d9}
359
- a{color:#58a6ff}h1{color:#f0f6fc}code{background:#161b22;padding:2px 6px;border-radius:4px}
360
- pre{background:#161b22;padding:16px;border-radius:8px;overflow-x:auto}
361
- .ep{background:#161b22;padding:16px;border-radius:8px;margin:12px 0}</style></head>
362
- <body>
363
- <h1>GraphStrike</h1>
364
- <p><strong>Coordinated Fake Account Ring Detection</strong> &mdash; OpenEnv RL Environment</p>
365
- <h3>API Endpoints</h3>
366
- <div class="ep"><pre>GET /health — liveness check
367
- GET /tasks — task list + action schema
368
- POST /reset — start episode {"task":"easy","seed":0}
369
- POST /step — take action {"action_type":"inspect","account_id":"acc_0012"}
370
- GET /state — episode state
371
- GET /grader — score after SUBMIT
372
- POST /baseline — rule-based agent on all 3 tasks
373
- GET /metadata — environment metadata
374
- GET /schema — action/observation/state schemas
375
- POST /mcp — MCP JSON-RPC 2.0</pre></div>
376
- <h3>Quick Test</h3>
377
- <pre>curl -X POST /reset -H "Content-Type: application/json" -d '{"task":"easy","seed":0}'
378
- curl -X POST /step -H "Content-Type: application/json" -d '{"action_type":"inspect","account_id":"acc_0000"}'
379
- curl -X POST /step -H "Content-Type: application/json" -d '{"action_type":"submit"}'
380
- curl /grader</pre>
381
- <p><a href="/docs">Interactive API docs (Swagger)</a> &nbsp;|&nbsp; <a href="/redoc">ReDoc</a></p>
382
- </body></html>"""
383
 
384
 
385
  # ---------------------------------------------------------------------------
386
  # Entry point
387
  # ---------------------------------------------------------------------------
388
 
389
- def main() -> None:
390
- """Start the environment server. PORT env var controls the port (default 7860)."""
391
- import os
392
  import uvicorn
393
  port = int(os.environ.get("PORT", 7860))
 
394
  uvicorn.run("server.app:app", host="0.0.0.0", port=port, log_level="info", workers=1)
395
 
396
-
397
  if __name__ == "__main__":
398
  main()
 
3
  from __future__ import annotations
4
 
5
  import json
6
+ import os
7
  import sys
 
8
  from pathlib import Path
9
 
 
10
  sys.path.insert(0, str(Path(__file__).parent))
11
  sys.path.insert(0, str(Path(__file__).parent.parent))
12
 
13
  from fastapi import FastAPI, HTTPException
14
+ from fastapi.responses import HTMLResponse, RedirectResponse
15
  from fastapi.middleware.cors import CORSMiddleware
16
  from pydantic import BaseModel
17
+ from typing import Any, Dict, Optional
18
 
19
  from models import FakeGangAction, FakeGangObservation, FakeGangState, ActionType
20
  from environment import FakeGangEnvironment
21
 
22
  # ---------------------------------------------------------------------------
23
+ # App
24
  # ---------------------------------------------------------------------------
25
 
26
  app = FastAPI(
 
31
 
32
  app.add_middleware(
33
  CORSMiddleware,
34
+ allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
 
 
35
  )
36
 
37
  _env = FakeGangEnvironment()
38
 
39
 
40
  # ---------------------------------------------------------------------------
41
+ # Schemas
42
  # ---------------------------------------------------------------------------
43
 
44
  class ResetRequest(BaseModel):
 
46
  seed: Optional[int] = None
47
  episode_id: Optional[str] = None
48
 
 
49
  class StepResponse(BaseModel):
50
  observation: Dict[str, Any]
51
  done: bool
 
54
 
55
 
56
  # ---------------------------------------------------------------------------
57
+ # OpenEnv API endpoints
58
  # ---------------------------------------------------------------------------
59
 
60
  @app.get("/health")
61
+ def health():
62
  return {"status": "healthy"}
63
 
 
64
  @app.post("/reset", response_model=StepResponse)
65
+ def reset(req: ResetRequest):
66
  obs = _env.reset(task=req.task, seed=req.seed, episode_id=req.episode_id)
67
+ return StepResponse(observation=obs.model_dump(), done=obs.done, reward=obs.reward, message=obs.message)
 
 
 
 
 
 
68
 
69
  @app.post("/step", response_model=StepResponse)
70
+ def step(action: FakeGangAction):
71
  obs = _env.step(action)
72
+ return StepResponse(observation=obs.model_dump(), done=obs.done, reward=obs.reward, message=obs.message)
 
 
 
 
 
 
73
 
74
  @app.get("/state")
75
+ def state():
76
  return _env.state.model_dump()
77
 
 
78
  @app.get("/tasks")
79
+ def list_tasks():
80
  return {
81
  "tasks": ["easy", "medium", "hard"],
82
  "descriptions": {
 
91
  "score_range": [0.0, 1.0],
92
  }
93
 
 
94
  @app.get("/grader")
95
+ def grader():
96
  if not _env._done:
97
  raise HTTPException(status_code=400, detail="Episode not complete. Call SUBMIT first.")
98
+ return {"score": _env._last_grader_score, "task": _env._task, "episode_id": _env._episode_id}
 
 
 
 
 
99
 
100
  @app.get("/metadata")
101
+ def metadata():
102
  return {
103
  "name": "graphstrike",
104
+ "description": "RL environment for detecting coordinated fake account rings in social networks.",
105
+ "version": "1.0.0", "author": "Pandago",
106
+ "tags": ["social-network", "fraud-detection", "graph", "rl"],
 
 
 
 
 
107
  }
108
 
 
109
  @app.get("/schema")
110
+ def schema():
111
  return {
112
  "action": FakeGangAction.model_json_schema(),
113
  "observation": FakeGangObservation.model_json_schema(),
114
  "state": FakeGangState.model_json_schema(),
115
  }
116
 
 
117
  @app.post("/mcp")
118
+ def mcp(body: Dict[str, Any] = {}):
 
119
  method = body.get("method", "")
120
  req_id = body.get("id", 1)
 
121
  if method == "tools/list":
122
+ return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": [
123
+ {"name": "reset", "description": "Reset the environment",
124
+ "inputSchema": {"type": "object", "properties": {"task": {"type": "string"}, "seed": {"type": "integer"}}}},
125
+ {"name": "step", "description": "Take an action", "inputSchema": FakeGangAction.model_json_schema()},
126
+ {"name": "state", "description": "Get episode state", "inputSchema": {"type": "object", "properties": {}}},
127
+ ]}}
128
+ return {"jsonrpc": "2.0", "id": req_id, "result": {"name": "graphstrike", "version": "1.0.0", "protocolVersion": "2024-11-05"}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  @app.post("/baseline")
131
+ def baseline():
132
  sys.path.insert(0, str(Path(__file__).parent.parent))
133
+ from inference import run_rule_based_episode
134
+ scores = {}
135
  for task in ["easy", "medium", "hard"]:
136
  scores[task] = run_rule_based_episode(_env, task=task, seed=0)
137
  return {"scores": scores, "agent": "rule_based"}
138
 
139
 
140
  # ---------------------------------------------------------------------------
141
+ # Gradio web interface — mounted at /
142
  # ---------------------------------------------------------------------------
143
 
 
 
144
  try:
145
  import gradio as gr
 
 
 
146
 
147
+ def _fmt_obs(d: dict) -> str:
148
  lines = []
149
+ lines.append(f"**Task:** {d.get('task','?')} | **Done:** {d.get('done',False)} | **Steps remaining:** {d.get('steps_remaining','?')}")
150
+ if d.get('reward') is not None:
151
+ lines.append(f"**Reward:** {d['reward']:.2f}")
152
+ fl = d.get('flagged_ids', [])
153
+ lines.append(f"**Flagged ({len(fl)}/10):** {fl}")
154
+ su = d.get('suspect_ids', [])
155
+ lines.append(f"**Suspects ({len(su)}):** {su}")
156
+ lines.append(f"**Visible:** {len(d.get('visible_account_ids',[]))} IDs | **Inspected:** {len(d.get('inspected_ids',[]))} accounts")
157
+ if d.get('evasion_triggered'):
158
+ lines.append(f"**Evasion events:** {d.get('evasion_count',0)}")
159
+ lines.append(f"**Message:** {d.get('message','')}")
 
 
 
 
160
  return "\n\n".join(lines)
161
 
162
+ def _fmt_profiles(d: dict) -> str:
163
+ accs = d.get("visible_accounts", [])
164
+ if not accs:
165
+ return "No accounts inspected yet. Use **INSPECT** to reveal profiles."
166
+ rows = ["| Account | Status | Risk | Node | Beh | Graph | Hub | Photo | Bio | F.Nbrs |",
167
+ "|---------|--------|------|------|-----|-------|-----|-------|-----|--------|"]
168
+ for a in sorted(accs, key=lambda x: x.get("fake_risk_score",0), reverse=True)[:25]:
169
+ rows.append(f"| {a.get('account_id','')} | {a.get('status','?')} | {a.get('fake_risk_score',0):.3f} "
170
+ f"| {a.get('node_risk',0):.2f} | {a.get('behavior_risk',0):.2f} | {a.get('graph_risk',0):.2f} "
171
+ f"| {a.get('hub_legitimacy_score',0):.2f} | {a.get('photo_reuse_score',0):.2f} "
172
+ f"| {a.get('bio_template_score',0):.2f} | {a.get('flagged_neighbor_count',0)} |")
 
 
 
 
 
 
 
 
 
 
 
 
173
  return "\n".join(rows)
174
 
175
  def gr_reset(task, seed):
176
  try:
177
+ obs = _env.reset(task=task, seed=int(seed))
178
  d = obs.model_dump()
179
  return _fmt_obs(d), _fmt_profiles(d), json.dumps(d, indent=2, default=str)
180
  except Exception as e:
 
184
  try:
185
  acc = account_id.strip() if action_type != "submit" else None
186
  action = FakeGangAction(action_type=ActionType(action_type), account_id=acc)
187
+ obs = _env.step(action)
188
  d = obs.model_dump()
189
  return _fmt_obs(d), _fmt_profiles(d), json.dumps(d, indent=2, default=str)
190
  except Exception as e:
191
  return f"**Error:** {e}", "", "{}"
192
 
193
  def gr_grader():
194
+ if not _env._done:
195
  return "Episode not complete. Call SUBMIT first."
196
+ return json.dumps({"score": _env._last_grader_score, "task": _env._task, "episode_id": _env._episode_id}, indent=2)
 
 
 
 
197
 
198
  def gr_baseline():
199
  sys.path.insert(0, str(Path(__file__).parent.parent))
200
  from inference import run_rule_based_episode
201
+ scores = {t: run_rule_based_episode(_env, task=t, seed=0) for t in ["easy", "medium", "hard"]}
 
 
202
  return json.dumps({"scores": scores, "agent": "rule_based"}, indent=2)
203
 
204
  with gr.Blocks(title="GraphStrike") as demo:
 
207
  "### Coordinated Fake Account Ring Detection — OpenEnv RL Environment\n\n"
208
  "Detect all 10 members of a coordinated fake account ring hidden in a social network.\n"
209
  "Use **INSPECT** to reveal profiles, **FLAG** to mark fakes, **SUBMIT** to end.\n\n"
210
+ "`/reset` `/step` `/state` `/grader` `/baseline` `/tasks` `/health` — [Swagger](/docs)"
211
  )
 
212
  with gr.Row():
213
+ with gr.Column():
214
  gr.Markdown("#### 1. Start Episode")
215
+ task_dd = gr.Dropdown(["easy","medium","hard"], value="easy", label="Task")
216
+ seed_in = gr.Number(value=0, label="Seed", precision=0)
217
  reset_btn = gr.Button("Reset Episode", variant="primary", size="lg")
218
+ with gr.Column():
 
219
  gr.Markdown("#### 2. Take Actions")
220
+ action_dd = gr.Dropdown(["inspect","investigate_network","flag","unflag","submit"], value="inspect", label="Action Type")
221
+ acc_in = gr.Textbox(label="Account ID", placeholder="e.g. acc_0012")
 
 
 
222
  step_btn = gr.Button("Step", variant="primary", size="lg")
223
 
224
+ obs_md = gr.Markdown(value="*Click 'Reset Episode' to begin.*")
 
225
  with gr.Accordion("Account Profiles (sorted by risk)", open=True):
226
+ prof_md = gr.Markdown(value="")
 
227
  with gr.Row():
228
  grader_btn = gr.Button("Get Grader Score")
229
  baseline_btn = gr.Button("Run Baseline (all 3 tasks)")
230
  result_box = gr.Textbox(label="Result", lines=5, interactive=False)
231
+ with gr.Accordion("Raw JSON", open=False):
 
232
  raw_json = gr.Textbox(label="Raw JSON", lines=15, interactive=False)
233
 
234
+ reset_btn.click(gr_reset, [task_dd, seed_in], [obs_md, prof_md, raw_json])
235
+ step_btn.click(gr_step, [action_dd, acc_in], [obs_md, prof_md, raw_json])
236
  grader_btn.click(gr_grader, [], result_box)
237
  baseline_btn.click(gr_baseline, [], result_box)
238
 
239
  app = gr.mount_gradio_app(app, demo, path="/")
 
240
  print("[GraphStrike] Gradio UI mounted at /", flush=True)
241
 
242
  except Exception as exc:
243
+ print(f"[GraphStrike] Gradio unavailable: {exc}", flush=True)
 
244
 
245
+ @app.get("/", response_class=HTMLResponse)
246
+ def root_fallback():
247
+ return "<html><body><h1>GraphStrike</h1><p>API-only mode. <a href='/docs'>Swagger</a></p></body></html>"
248
 
 
 
 
249
 
250
+ # HF Spaces probes /web — redirect to root (Gradio UI)
251
+ @app.get("/web", response_class=RedirectResponse)
252
+ def web_redirect():
253
+ return RedirectResponse(url="/")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
 
256
  # ---------------------------------------------------------------------------
257
  # Entry point
258
  # ---------------------------------------------------------------------------
259
 
260
+ def main():
 
 
261
  import uvicorn
262
  port = int(os.environ.get("PORT", 7860))
263
+ print(f"[GraphStrike] Starting on port {port}", flush=True)
264
  uvicorn.run("server.app:app", host="0.0.0.0", port=port, log_level="info", workers=1)
265
 
 
266
  if __name__ == "__main__":
267
  main()