File size: 24,129 Bytes
dd82d4f
ca7e89f
 
 
6025d24
dd82d4f
6025d24
 
 
c47b19a
6025d24
dd82d4f
 
949a94e
f6ad663
 
 
 
dd82d4f
 
 
ca7e89f
dd82d4f
 
6025d24
 
ca7e89f
f114961
a7cb63d
f6ad663
 
 
 
 
 
ca7e89f
6025d24
53a8778
 
4a8a672
6025d24
c47b19a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc9640e
099deaf
 
 
c47b19a
 
099deaf
 
c47b19a
099deaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c47b19a
099deaf
c47b19a
099deaf
c47b19a
 
099deaf
f9c6cbc
 
099deaf
c47b19a
099deaf
 
 
 
 
f9c6cbc
099deaf
f9c6cbc
099deaf
f9c6cbc
6025d24
 
ca7e89f
6025d24
f6ad663
6025d24
 
 
f6ad663
6025d24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd82d4f
6025d24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c47b19a
6025d24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca7e89f
2dfcf50
 
 
 
 
 
 
f6ad663
2dfcf50
 
ca7e89f
f6ad663
2dfcf50
ca7e89f
 
2dfcf50
 
f6ad663
2dfcf50
f6ad663
ca7e89f
 
 
2dfcf50
ca7e89f
 
 
 
 
2dfcf50
f6ad663
ca7e89f
 
 
daaa149
ca7e89f
daaa149
 
ca7e89f
 
 
 
 
 
 
 
 
daaa149
ca7e89f
 
 
 
 
 
 
 
 
 
 
 
 
c47b19a
099deaf
c47b19a
099deaf
 
 
c47b19a
 
 
f6ad663
c47b19a
 
 
099deaf
 
 
c47b19a
 
099deaf
c47b19a
099deaf
 
c47b19a
 
 
 
099deaf
 
 
c47b19a
099deaf
 
 
 
 
 
 
 
c47b19a
 
 
ca7e89f
 
 
 
 
c47b19a
 
 
 
ca7e89f
 
 
2dfcf50
 
 
ca7e89f
 
 
3e1d372
 
 
ca7e89f
 
 
 
2dfcf50
ca7e89f
 
 
2dfcf50
ca7e89f
 
 
 
 
3e1d372
ca7e89f
f114961
ca7e89f
f114961
 
ca7e89f
f114961
ca7e89f
 
daaa149
ca7e89f
 
 
dd82d4f
ca7e89f
dd82d4f
 
6025d24
ca7e89f
 
 
c47b19a
 
 
6025d24
dd82d4f
 
 
 
6025d24
 
 
 
 
dd82d4f
6025d24
dd82d4f
6025d24
dd82d4f
 
 
 
 
 
 
6025d24
dd82d4f
 
 
 
 
 
6025d24
dd82d4f
 
 
 
 
 
 
6025d24
 
 
 
 
 
 
 
 
 
 
 
dd82d4f
ca7e89f
 
 
dd82d4f
ca7e89f
 
 
dd82d4f
 
 
 
 
 
 
 
 
 
6025d24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd82d4f
 
 
6025d24
b360381
6025d24
b360381
dd82d4f
 
 
6025d24
 
dd82d4f
4835868
7470a2a
6025d24
 
 
b360381
ca7e89f
 
 
 
cfa1b2b
 
 
 
 
 
 
 
 
 
 
ca7e89f
 
1c97666
 
 
 
 
ca7e89f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f114961
 
1c97666
ca7e89f
1c97666
 
f114961
 
1c97666
 
 
 
ca7e89f
 
 
4101a93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca7e89f
 
 
 
 
 
0a0a4fc
3e1d372
f114961
3e1d372
f114961
0a0a4fc
f114961
0a0a4fc
 
 
 
 
 
 
 
f6ad663
1acf997
53a8778
f114961
 
1c97666
ca7e89f
1c97666
 
 
1acf997
1c97666
f114961
 
1c97666
 
 
 
ca7e89f
 
 
0a0a4fc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca7e89f
7654594
b360381
 
6025d24
b360381
0729b9c
 
 
 
b360381
dd82d4f
7654594
f6ad663
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
"""
SentinelEnv / Arya-X server — Flask API + dashboard UI.
Single-agent endpoints preserved; multi-agent endpoints added under /reset_multi,
/step_multi, /auto_multi.
Run: python server.py
"""
import os
import json
import random as _random
from pathlib import Path
from flask import Flask, request, jsonify, render_template
from env import SentinelEnv
from env.models import Action
from env.multiagent import AryaXEnv, Proposal, AGENT_TYPES
from agents.satellite import SatelliteAgent
from agents.drone import DroneAgent
from agents.radar import RadarAgent
from agents.command import CommandAgent

app = Flask(__name__)

# ── Single-agent env (existing) ───────────────────────────────────────────────
env = SentinelEnv(max_steps=10, seed=42)
obs = None
_target_positions: dict = {}

# ── Multi-agent env ───────────────────────────────────────────────────────────
mx_env = AryaXEnv(max_steps=10, seed=42, mode='single')
mx_obs = None   # Dict[str, AgentObservation] | None

sat_agent = SatelliteAgent()
drone_agent = DroneAgent()
radar_agent = RadarAgent()
command_agent = CommandAgent()


_llm_client = None
API_BASE_URL = os.environ.get("API_BASE_URL", "https://router.huggingface.co/v1")
MODEL_NAME   = os.environ.get("MODEL_NAME", "meta-llama/Llama-3.2-3B-Instruct")
HF_TOKEN     = os.environ.get("HF_TOKEN")

_base_model = None
_tokenizer = None
_has_adapters = False

# Fallback mappings for local checkpoints
AGENT_ID_MAP = {"satellite": "SAT", "drone": "UAV", "radar": "RDR", "command": "CMD"}

try:
    import torch
    from transformers import AutoTokenizer, AutoModelForCausalLM
    from peft import PeftModel
    _LOCAL_HF_AVAILABLE = True
except ImportError:
    _LOCAL_HF_AVAILABLE = False

def init_local_models():
    global _base_model, _tokenizer, _has_adapters
    if not _LOCAL_HF_AVAILABLE:
        print("[WARN] transformers or peft not installed. Will use greedy fallback.")
        return

    checkpoint_dir = Path("./checkpoints/arya_x_lora")
    adapter_file = checkpoint_dir / "adapter_model.safetensors"
    if not adapter_file.exists():
        print(f"[WARN] No adapter found at {checkpoint_dir}. Will use greedy fallback.")
        return

    adapter_config = checkpoint_dir / "adapter_config.json"
    base_model_name = MODEL_NAME
    try:
        with open(adapter_config) as f:
            cfg = json.load(f)
            base_model_name = cfg.get("base_model_name_or_path", MODEL_NAME)
    except Exception:
        pass

    print(f"[LLM] Loading base model ({base_model_name}) + LoRA adapter...")
    try:
        _tokenizer = AutoTokenizer.from_pretrained(str(checkpoint_dir), padding_side="left")
        if not torch.cuda.is_available():
            print("[WARN] No GPU detected — skipping local adapter load. Using remote API.")
            return
        load_kwargs = {"load_in_4bit": True, "device_map": "auto"}
        base = AutoModelForCausalLM.from_pretrained(base_model_name, **load_kwargs)
        _base_model = PeftModel.from_pretrained(base, str(checkpoint_dir))
        _has_adapters = True
        print("[LLM] LoRA adapter loaded successfully.")
    except Exception as e:
        print(f"[ERROR] Failed to load adapter: {e}")
        _has_adapters = False

# Initialize — try local adapter first, fall back to remote API
import sys
print(f"[ENV] HF_TOKEN set: {bool(HF_TOKEN)}, MODEL_NAME: {MODEL_NAME}", flush=True, file=sys.stderr)
init_local_models()

if not _has_adapters:
    if HF_TOKEN:
        try:
            from openai import OpenAI
            _llm_client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
            print(f"[LLM] Connected to remote API: {MODEL_NAME}", flush=True, file=sys.stderr)
        except Exception as e:
            print(f"[LLM] Failed to init client: {e}. Using greedy fallback.", flush=True, file=sys.stderr)
    else:
        print("[LLM] No HF_TOKEN set — using greedy fallback.", flush=True, file=sys.stderr)


# ── Single-agent helpers (unchanged) ─────────────────────────────────────────
def _build_prompt(observation) -> str:
    sensors = "\n".join(
        f"  - id={s.id} type={s.type} range={s.range}km available={s.available}"
        for s in observation.sensors if s.available
    )
    targets = "\n".join(
        f"  - id={t.id} priority={t.priority} active={t.active}"
        for t in observation.targets if t.active
    )
    n = sum(1 for s in observation.sensors if s.available)
    return f"""You are a military sensor allocation AI. Assign ALL available sensors to threats.
Priority 3=HIGH (missile/critical), 2=MED (border movement), 1=LOW (airspace).
Always cover HIGH priority threats first. Each sensor must go to a DIFFERENT target.

Timestep: {observation.timestep}

Available Sensors ({n}):
{sensors}

Active Threats:
{targets}

Respond ONLY with a JSON array of assignments, one per available sensor:
[{{"sensor_id": "S1", "target_id": "T0_1"}}, {{"sensor_id": "S2", "target_id": "T0_2"}}]
"""


def _parse_llm_actions(text: str, observation) -> list[Action]:
    try:
        start = text.find("[")
        end   = text.rfind("]") + 1
        data  = json.loads(text[start:end])
        valid_sensors = {s.id for s in observation.sensors if s.available}
        valid_targets = {t.id for t in observation.targets if t.active}
        actions, used_sensors, used_targets = [], set(), set()
        for item in data:
            sid, tid = item.get("sensor_id"), item.get("target_id")
            if (sid in valid_sensors and tid in valid_targets
                    and sid not in used_sensors and tid not in used_targets):
                actions.append(Action(sensor_id=sid, target_id=tid))
                used_sensors.add(sid)
                used_targets.add(tid)
        return actions
    except Exception:
        return []


def _greedy_actions(observation) -> list[Action]:
    available = [s for s in observation.sensors if s.available]
    targets   = sorted([t for t in observation.targets if t.active], key=lambda t: -t.priority)
    actions, used = [], set()
    for sensor in available:
        for target in targets:
            if target.id not in used:
                actions.append(Action(sensor_id=sensor.id, target_id=target.id))
                used.add(target.id)
                break
    return actions


def _get_actions(observation) -> tuple[list[Action], str]:
    if _llm_client and not _has_adapters: # Only use remote if local is missing
        try:
            prompt   = _build_prompt(observation)
            response = _llm_client.chat.completions.create(
                model=MODEL_NAME,
                messages=[{"role": "user", "content": prompt}],
                max_tokens=256,
                temperature=0.0
            )
            raw     = response.choices[0].message.content.strip()
            actions = _parse_llm_actions(raw, observation)
            if actions:
                return actions, "llm"
            print(f"[LLM] Bad response, falling back. Raw: {raw!r}")
        except Exception as e:
            print(f"[LLM] Error: {e}. Falling back to greedy.")
    return _greedy_actions(observation), "greedy"


# ── Multi-agent helpers ───────────────────────────────────────────────────────
def _build_multi_prompt(agent_id: str, agent_obs, used_sensors: set = None) -> str:
    used_sensors = used_sensors or set()
    my_sensors = [
        s for s in agent_obs.sensors
        if s["available"] and s["id"] not in used_sensors
        and (agent_id == "command" or s["type"] == agent_id)
    ]
    sensors = "\n".join(
        f"  - id={s['id']} type={s['type']} range={s['range']}km"
        for s in my_sensors
    )
    targets = "\n".join(
        f"  - id={t['id']} priority={t['priority']}"
        for t in agent_obs.targets if t["active"]
    )
    if not my_sensors:
        return ""
    return f"""You are the {agent_id} agent in a multi-agent ISR system.
You may ONLY assign YOUR sensors listed below. Do NOT use sensors belonging to other agents.
Priority 3=HIGH, 2=MED, 1=LOW. Cover HIGH threats first.

Timestep: {agent_obs.timestep}

Your sensors ({agent_id} type only):
{sensors}

Active Threats:
{targets}

Respond ONLY with a JSON array using only your sensors above:
[{{"sensor_id": "S1", "target_id": "T0_1"}}]
"""


def _greedy_proposals(agent_id: str, agent_obs, used_sensors: set, used_targets: set = None) -> list[Proposal]:
    """Greedy proposals for one agent — only claim sensors matching agent type."""
    if used_targets is None:
        used_targets = set()
    my_sensors = [
        s for s in agent_obs.sensors
        if s["available"] and s["id"] not in used_sensors
        and (agent_id == "command" or s["type"] == agent_id)
    ]
    targets = sorted(
        [t for t in agent_obs.targets if t["active"]],
        key=lambda t: -t["priority"]
    )
    proposals = []
    for sensor in my_sensors:
        for target in targets:
            if target["id"] not in used_targets:
                proposals.append(Proposal(
                    agent_id=agent_id,
                    sensor_id=sensor["id"],
                    target_id=target["id"]
                ))
                used_targets.add(target["id"])
                break
    return proposals


def _lora_multi_proposals(agent_obs_map) -> tuple[list[Proposal], str]:
    """Build multi-agent proposals using single shared LoRA adapter."""
    proposals: list[Proposal] = []
    used_sensors: set = set()
    used_targets: set = set()

    for agent_id in AGENT_TYPES:
        agent_obs = agent_obs_map[agent_id]
        prompt = _build_multi_prompt(agent_id, agent_obs)
        chat_prompt = f"<|begin_of_text|><|start_header_id|>user<|end_header_id|>\n\n{prompt}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n"
        try:
            inputs = _tokenizer(chat_prompt, return_tensors="pt").to(_base_model.device)
            outputs = _base_model.generate(**inputs, max_new_tokens=128, temperature=0.1, do_sample=True, pad_token_id=_tokenizer.eos_token_id)
            raw = _tokenizer.decode(outputs[0], skip_special_tokens=True)
            if "assistant" in raw:
                raw = raw.split("assistant")[-1].strip()
            start, end = raw.find("["), raw.rfind("]") + 1
            if start == -1 or end == 0:
                raise ValueError("no JSON array")
            items = json.loads(raw[start:end])
            valid_sensors = {s["id"] for s in agent_obs.sensors if s["available"] and s["id"] not in used_sensors}
            valid_targets = {t["id"] for t in agent_obs.targets if t["active"] and t["id"] not in used_targets}
            for item in items:
                sid, tid = item.get("sensor_id"), item.get("target_id")
                if sid in valid_sensors and tid in valid_targets:
                    proposals.append(Proposal(agent_id=agent_id, sensor_id=sid, target_id=tid))
                    used_sensors.add(sid)
                    used_targets.add(tid)
                    break
        except Exception as e:
            print(f"[WARN] LoRA generation failed for {agent_id}: {e} — using greedy")
            cmd_obs = agent_obs_map["command"]
            for p in _greedy_proposals(agent_id, cmd_obs, used_sensors, set(used_targets)):
                proposals.append(p)
                used_sensors.add(p.sensor_id)
                used_targets.add(p.target_id)
                break

    return proposals, "lora"


def _get_multi_proposals(agent_obs_map) -> tuple[list[Proposal], str]:
    """Build proposals from all agents. Returns (proposals, source)."""
    proposals: list[Proposal] = []
    used_sensors: set = set()

    if _has_adapters:
        return _lora_multi_proposals(agent_obs_map)

    if _llm_client and not _has_adapters: # API fallback
        try:
            for agent_id in AGENT_TYPES:
                agent_obs = agent_obs_map[agent_id]
                prompt = _build_multi_prompt(agent_id, agent_obs, used_sensors)
                if not prompt:
                    continue
                response = _llm_client.chat.completions.create(
                    model=MODEL_NAME,
                    messages=[{"role": "user", "content": prompt}],
                    max_tokens=64,
                    temperature=0.0,
                    timeout=10
                )
                raw = response.choices[0].message.content.strip()
                start, end = raw.find("["), raw.rfind("]") + 1
                items = json.loads(raw[start:end])
                my_sensors = {s["id"] for s in agent_obs.sensors if s["available"] and s["id"] not in used_sensors and (agent_id == "command" or s["type"] == agent_id)}
                valid_targets = {t["id"] for t in agent_obs.targets if t["active"]}
                for item in items:
                    sid, tid = item.get("sensor_id"), item.get("target_id")
                    if sid in my_sensors and tid in valid_targets:
                        proposals.append(Proposal(agent_id=agent_id, sensor_id=sid, target_id=tid))
                        used_sensors.add(sid)
            if proposals:
                return proposals, "llm"
        except Exception as e:
            print(f"[LLM multi] Error: {e}. Falling back to greedy.", flush=True, file=sys.stderr)

    # Greedy fallback — shared used_sensors + used_targets prevents duplicates
    used_sensors = set()
    used_targets: set = set()
    cmd_obs = agent_obs_map["command"]
    for agent_id in AGENT_TYPES:
        for p in _greedy_proposals(agent_id, cmd_obs, used_sensors, used_targets):
            proposals.append(p)
            used_sensors.add(p.sensor_id)
            used_targets.add(p.target_id)

    return proposals, "greedy"


# ── Single-agent routes (unchanged) ──────────────────────────────────────────
@app.get("/status")
def status():
    return jsonify({
        "status":       "ok",
        "obs_ready":    obs is not None,
        "mx_obs_ready": mx_obs is not None,
        "llm_enabled":  _llm_client is not None or _has_adapters,
        "model":        MODEL_NAME if _llm_client or _has_adapters else None,
        "lora_active":  _has_adapters
    })


@app.post("/reset")
def reset():
    global obs, _target_positions
    body      = request.get_json(silent=True) or {}
    seed      = body.get("seed") or _random.randint(1, 99999)
    max_steps = body.get("max_steps", 10)
    env.seed      = seed
    env.max_steps = max_steps
    _target_positions = {}
    obs = env.reset()
    return jsonify({**obs.model_dump(), "seed": seed})


@app.post("/step")
def step():
    global obs
    if obs is None:
        return jsonify({"error": "Call /reset first"}), 400
    body      = request.get_json(silent=True) or {}
    sensor_id = body.get("sensor_id")
    target_id = body.get("target_id")
    if not sensor_id or not target_id:
        return jsonify({"error": "Provide sensor_id and target_id"}), 400
    action = Action(sensor_id=sensor_id, target_id=target_id)
    obs, reward, done, info = env.step(action)
    return jsonify({"observation": obs.model_dump(), "reward": reward, "done": done, "info": info})


@app.post("/step/auto")
def step_auto():
    global obs
    if obs is None:
        return jsonify({"error": "Call /reset first"}), 400

    available = [s for s in obs.sensors if s.available]
    active    = [t for t in obs.targets if t.active]

    if not available or not active:
        obs, reward, done, info = env.step_batch([])
        return jsonify({"actions": [], "action": None, "agent": "idle",
                        "observation": obs.model_dump(), "reward": reward, "done": done, "info": info})

    actions, source = _get_actions(obs)
    obs, total_reward, done, info = env.step_batch(actions)

    return jsonify({
        "actions":     [a.model_dump() for a in actions],
        "action":      actions[0].model_dump() if actions else None,
        "agent":       source,
        "observation": obs.model_dump(),
        "reward":      total_reward,
        "done":        done,
        "info":        info
    })


@app.get("/state")
def state():
    if obs is None:
        return jsonify({"error": "Call /reset first"}), 400
    return jsonify(obs.model_dump())


@app.post("/targets/custom")
def register_custom_target():
    global obs
    if obs is None:
        return jsonify({"error": "Call /reset first"}), 400
    body     = request.get_json(silent=True) or {}
    tid      = body.get("id")
    priority = body.get("priority", 2)
    lat      = body.get("lat")
    lon      = body.get("lon")
    if not tid or lat is None or lon is None:
        return jsonify({"error": "Provide id, lat, lon"}), 400
    from env.models import Target
    _target_positions[tid] = [lat, lon]
    env.targets.append(Target(id=tid, priority=priority, active=True))
    obs = env.state()
    return jsonify({"ok": True, "id": tid})


@app.post("/grade")
def grade():
    from tasks.grader import grade_episode
    body  = request.get_json(silent=True) or {}
    steps = body.get("max_steps", env.max_steps)
    seed  = body.get("seed") or _random.randint(1, 99999)
    g_env = SentinelEnv(max_steps=steps, seed=seed)
    g_obs = g_env.reset()
    total_reward, done = 0.0, False
    while not done:
        actions, _ = _get_actions(g_obs)
        g_obs, reward, done, info = g_env.step_batch(actions)
        total_reward += reward
    score = grade_episode(total_reward, info["step_count"], num_sensors=g_env.initial_sensor_count)
    score = max(0.01, min(0.99, score))
    return jsonify({"score": score, "total_reward": total_reward,
                    "steps": info["step_count"], "seed": seed})


# ── Multi-agent routes ────────────────────────────────────────────────────────
@app.post("/reset_multi")
def reset_multi():
    global mx_obs
    body             = request.get_json(silent=True) or {}
    seed             = body.get("seed") or _random.randint(1, 99999)
    max_steps        = body.get("max_steps", 10)
    density_factor   = body.get("density_factor", 1.5)
    failure_prob     = body.get("failure_prob", 0.0)
    conflict_injection = body.get("conflict_injection", False)
    mx_env.seed              = seed
    mx_env.max_steps         = max_steps
    mx_env.density_factor    = density_factor
    mx_env.failure_prob      = failure_prob
    mx_env.conflict_injection = conflict_injection
    mx_obs = mx_env.reset()
    return jsonify({
        "seed":            seed,
        "max_steps":       max_steps,
        "observations":    {k: v.to_dict() for k, v in mx_obs.items()},
        "conflict_rate":   0.0,
        "per_agent_rewards": {a: 0.0 for a in AGENT_TYPES},
    })


@app.post("/step_multi")
def step_multi():
    global mx_obs
    if mx_obs is None:
        return jsonify({"error": "Call /reset_multi first"}), 400

    body = request.get_json(silent=True) or {}
    raw_proposals = body.get("proposals", [])

    proposals = [
        Proposal(
            agent_id=p["agent_id"],
            sensor_id=p["sensor_id"],
            target_id=p["target_id"]
        )
        for p in raw_proposals
        if p.get("agent_id") and p.get("sensor_id") and p.get("target_id")
    ]

    mx_obs, step_rewards, done, info = mx_env.step_multiagent(proposals)

    conflict_rate = info["conflict_rate"]
    conflicts     = info["conflicts"]

    return jsonify({
        "observations":      {k: v.to_dict() for k, v in mx_obs.items()},
        "step_rewards":      step_rewards,
        "agent_rewards":     info["agent_rewards"],
        "per_agent_rewards": info["agent_rewards"],
        "done":              done,
        "info":              info,
        "conflict_rate":     round(conflict_rate, 4),
        "conflicts":         conflicts,
    })


def _filter_proposals(proposals: list, agent_obs_map: dict) -> list:
    """Drop any proposal where the agent claims a sensor not matching their type."""
    filtered = []
    used_sensors, used_targets = set(), set()
    for p in proposals:
        agent_id = p.agent_id
        agent_obs = agent_obs_map.get(agent_id)
        if not agent_obs:
            continue
        sensor_type = next((s["type"] for s in agent_obs.sensors if s["id"] == p.sensor_id), None)
        if sensor_type is None:
            continue
        if agent_id != "command" and sensor_type != agent_id:
            continue  # wrong sensor type for this agent
        if p.sensor_id in used_sensors or p.target_id in used_targets:
            continue
        filtered.append(p)
        used_sensors.add(p.sensor_id)
        used_targets.add(p.target_id)
    return filtered


@app.post("/auto_multi")
def auto_multi():
    global mx_obs
    if mx_obs is None:
        return jsonify({"error": "Call /reset_multi first"}), 400

    if _has_adapters or _llm_client:
        print(f"[auto_multi] Using {'lora' if _has_adapters else 'llm'}", flush=True, file=sys.stderr)
        proposals, source = _get_multi_proposals(mx_obs)
        print(f"[auto_multi] Got {len(proposals)} proposals from {source}", flush=True, file=sys.stderr)
    else:
        # Use wired agent classes (not raw greedy helper)
        proposals = []
        all_props = []
        for agent_id, agent in [("satellite", sat_agent), ("drone", drone_agent), ("radar", radar_agent)]:
            agent.observe(mx_obs[agent_id])
            all_props += agent.propose()
        command_agent.observe(mx_obs["command"], proposals=all_props)
        all_props += command_agent.propose()
        proposals = all_props
        source = "agents"

    new_obs, step_rewards, done, info = mx_env.step_multiagent(proposals)
    mx_obs = new_obs  # keep obs even when done so state is readable
    conflict_rate = info["conflict_rate"]
    conflicts     = info["conflicts"]

    return jsonify({
        "proposals":         [{"agent_id": p.agent_id, "sensor_id": p.sensor_id,
                               "target_id": p.target_id} for p in proposals],
        "agent":             source,
        "observations":      {k: v.to_dict() for k, v in new_obs.items()},
        "step_rewards":      step_rewards,
        "agent_rewards":     info["agent_rewards"],
        "per_agent_rewards": info["agent_rewards"],
        "done":              done,
        "info":              info,
        "conflict_rate":     round(conflict_rate, 4),
        "conflicts":         conflicts,
    })


# ── Metrics history endpoint ─────────────────────────────────────────────────
@app.get("/metrics/history")
def metrics_history():
    metrics_path = Path("./logs/training_metrics.json")
    if not metrics_path.exists():
        return jsonify([])
    try:
        with open(metrics_path) as f:
            data = json.load(f)
        # Support both array format (per-episode) and legacy single-object format
        if isinstance(data, dict):
            data = [data]
        return jsonify(data)
    except Exception as e:
        return jsonify({"error": str(e)}), 500


# ── UI ────────────────────────────────────────────────────────────────────────
@app.get("/")
@app.get("/ui")
def ui():
    return render_template("dashboard.html")

@app.get("/game")
def game():
    return render_template("game.html")


if __name__ == "__main__":
    port = int(os.environ.get("PORT", 7860))
    app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False)