File size: 28,053 Bytes
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
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
"""


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 interaction import NegotiationLayer

from interaction.reward import RewardEngine

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)

mx_obs        = None   # Dict[str, AgentObservation] | None

_negotiation  = NegotiationLayer()

_reward_eng   = RewardEngine()



sat_agent = SatelliteAgent()

drone_agent = DroneAgent()

radar_agent = RadarAgent()

command_agent = CommandAgent()



CAPABILITY_MATRIX = {

    ("satellite","strategic"):0.95, ("satellite","kinetic"):0.40, ("satellite","airspace"):0.60,

    ("drone","kinetic"):0.95, ("drone","strategic"):0.30, ("drone","airspace"):0.50,

    ("radar","airspace"):0.95, ("radar","kinetic"):0.65, ("radar","strategic"):0.45,

}



# ΓöÇΓöÇ LLM client (optional) ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ

_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/Meta-Llama-3-8B-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():

    """Initializes local transformers model and loads LoRA adapters if available."""

    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")

    if not checkpoint_dir.exists():

        print(f"[WARN] No checkpoints found at {checkpoint_dir}. Will use greedy fallback.")

        return



    print(f"[LLM] Loading base model ({MODEL_NAME}) locally...")

    try:

        _tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, padding_side="left")

        load_kwargs = {}

        if torch.cuda.is_available():

            load_kwargs["load_in_4bit"] = True

            load_kwargs["device_map"] = "auto"

        

        base_model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, **load_kwargs)

        

        first_agent_id = "SAT"

        first_adapter_path = checkpoint_dir / f"adapter_{first_agent_id}"

        if not first_adapter_path.exists():

             print(f"[WARN] Missing adapter {first_adapter_path}. Using greedy.")

             return

             

        _base_model = PeftModel.from_pretrained(base_model, str(first_adapter_path), adapter_name=first_agent_id)

        

        for agent_canonical, agent_short in AGENT_ID_MAP.items():

            if agent_short == first_agent_id:

                continue

            adapter_path = checkpoint_dir / f"adapter_{agent_short}"

            if adapter_path.exists():

                _base_model.load_adapter(str(adapter_path), adapter_name=agent_short)

        

        _has_adapters = True

        print("[LLM] Local model and LoRA adapters loaded successfully.")

    except Exception as e:

        print(f"[ERROR] Failed to load local model/adapters: {e}")

        _has_adapters = False



# Run local init synchronously before requests

print("\\nInitializing Environment and Models...")

init_local_models()



if HF_TOKEN and not _has_adapters:

    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}")

    except Exception as e:

        print(f"[LLM] Failed to init client: {e}. Using greedy fallback.")

elif not _has_adapters:

    print("[LLM] No HF_TOKEN set and local adapters missing ΓÇö using greedy fallback.")





# ΓöÇΓöÇ 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) -> str:

    sensors = "\\n".join(

        f"  - id={s['id']} type={s['type']} range={s['range']}km available={s['available']}"

        for s in agent_obs.sensors if s["available"]

    )

    targets = "\\n".join(

        f"  - id={t['id']} priority={t['priority']} active={t['active']}"

        for t in agent_obs.targets if t["active"]

    )

    return f"""You are the {agent_id} agent in a multi-agent ISR system.


Propose assignments for sensors you are responsible for.


Priority 3=HIGH, 2=MED, 1=LOW. Cover HIGH threats first.





Timestep: {agent_obs.timestep}


Your agent type: {agent_id}





Available Sensors:


{sensors}





Active Threats:


{targets}





Respond ONLY with a JSON array:


[{{"sensor_id": "S1", "target_id": "T0_1"}}]


"""





def _greedy_proposals(agent_id: str, agent_obs, used_sensors: set) -> list[Proposal]:

    """Greedy proposals for one agent ΓÇö only claim sensors matching agent type."""

    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, used_targets = [], set()

    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 local LoRA adapters."""

    proposals: list[Proposal] = []

    

    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:

            short_id = AGENT_ID_MAP[agent_id]

            _base_model.set_adapter(short_id)

            

            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)

            full_response = _tokenizer.decode(outputs[0], skip_special_tokens=True)

            

            if "assistant" in full_response:

                raw = full_response.split("assistant")[-1].strip()

            else:

                raw = full_response

                

            start, end = raw.find("["), raw.rfind("]") + 1

            if start == -1 or end == 0:

                continue

            items = json.loads(raw[start:end])

            

            valid_sensors = {s["id"] for s in agent_obs.sensors if s["available"]}

            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 valid_sensors and tid in valid_targets:

                    proposals.append(Proposal(agent_id=agent_id, sensor_id=sid, target_id=tid))

        except Exception as e:

            print(f"[WARN] LoRA generation failed for {agent_id}: {e}")

            

    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)

                response = _llm_client.chat.completions.create(

                    model=MODEL_NAME,

                    messages=[{"role": "user", "content": prompt}],

                    max_tokens=128,

                    temperature=0.0

                )

                raw = response.choices[0].message.content.strip()

                start, end = raw.find("["), raw.rfind("]") + 1

                items = json.loads(raw[start:end])

                valid_sensors = {s["id"] for s in agent_obs.sensors if s["available"]}

                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 valid_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.")



    # Greedy fallback ΓÇö agents claim sensors by type

    used_sensors = set()

    for agent_id in AGENT_TYPES:

        agent_obs = agent_obs_map[agent_id]

        for p in _greedy_proposals(agent_id, agent_obs, used_sensors):

            proposals.append(p)

            used_sensors.add(p.sensor_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)

    mx_env.seed      = seed

    mx_env.max_steps = max_steps

    mx_obs = mx_env.reset()

    _negotiation.reset()

    _reward_eng.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")

    ]



    # Build world_state from satellite observation

    sat_obs = mx_obs.get("satellite") or next(iter(mx_obs.values()))

    sensors_dict  = sat_obs.sensors

    targets_dict  = sat_obs.targets

    

    proposals_raw = []

    for p in proposals:

        sensor_type = next((s["type"] for s in sensors_dict if s["id"] == p.sensor_id), "unknown")

        target_type = next((t.get("type", "strategic") for t in targets_dict if t["id"] == p.target_id), "strategic")

        cap_score = CAPABILITY_MATRIX.get((sensor_type, target_type), 0.5)

        proposals_raw.append({

            "agent_id": p.agent_id, 

            "sensor_id": p.sensor_id,

            "target_id": p.target_id, 

            "capability_score": getattr(p, "confidence", cap_score)

        })



    assigned_sids = {p.sensor_id for p in proposals}

    idle_sensors  = [s["id"] for s in sensors_dict if s["id"] not in assigned_sids]

    world_state = {

        "sensors":      sensors_dict,

        "targets":      targets_dict,

        "idle_sensors": idle_sensors,

        "step":         sat_obs.timestep,

        "proposals":    proposals_raw,

    }



    # Run NegotiationLayer (interaction/ package)

    neg_result = _negotiation.negotiate(

        proposals_raw, world_state, lambda tied: tied[0]

    )



    # Step env with resolved proposals

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



    # Compute per-agent rewards via RewardEngine

    per_agent_rewards = _reward_eng.compute_step_reward(

        neg_result.final_assignments, neg_result, world_state, list(AGENT_TYPES)

    )



    # Build conflict list from neg_result

    conflicts = [

        {

            "type":      str(c.conflict_type),

            "agents":    c.involved_agents,

            "sensor_id": c.involved_sensors[0] if c.involved_sensors else None,

            "target_id": c.involved_targets[0] if c.involved_targets else None,

        }

        for c in neg_result.conflicts_detected

    ]

    conflict_rate = _negotiation.get_conflict_rate()



    return jsonify({

        "observations":      {k: v.to_dict() for k, v in mx_obs.items()},

        "step_rewards":      step_rewards,

        "per_agent_rewards": per_agent_rewards,

        "done":              done,

        "info":              info,

        "conflict_rate":     round(conflict_rate, 4),

        "conflicts":         conflicts,

        "override_invoked":  neg_result.override_invoked,

    })





@app.post("/auto_multi")

def auto_multi():

    global mx_obs

    if mx_obs is None:

        return jsonify({"error": "Call /reset_multi first"}), 400



    sat_agent.observe(mx_obs["satellite"])

    drone_agent.observe(mx_obs["drone"])

    radar_agent.observe(mx_obs["radar"])

    command_agent.observe(mx_obs["command"])



    proposals = []

    proposals += sat_agent.propose()

    proposals += drone_agent.propose()

    proposals += radar_agent.propose()

    proposals += command_agent.propose()



    source = "multi-agent"



    # Build world_state from satellite observation

    sat_obs = mx_obs.get("satellite") or next(iter(mx_obs.values()))

    sensors_dict  = sat_obs.sensors

    targets_dict  = sat_obs.targets

    

    proposals_raw = []

    for p in proposals:

        sensor_type = next((s["type"] for s in sensors_dict if s["id"] == p.sensor_id), "unknown")

        target_type = next((t.get("type", "strategic") for t in targets_dict if t["id"] == p.target_id), "strategic")

        cap_score = CAPABILITY_MATRIX.get((sensor_type, target_type), 0.5)

        proposals_raw.append({

            "agent_id": p.agent_id, 

            "sensor_id": p.sensor_id,

            "target_id": p.target_id, 

            "capability_score": getattr(p, "confidence", cap_score)

        })



    assigned_sids = {p.sensor_id for p in proposals}

    idle_sensors  = [s["id"] for s in sensors_dict if s["id"] not in assigned_sids]

    world_state = {

        "sensors":      sensors_dict,

        "targets":      targets_dict,

        "idle_sensors": idle_sensors,

        "step":         sat_obs.timestep,

        "proposals":    proposals_raw,

    }



    # Run NegotiationLayer (interaction/ package)

    neg_result = _negotiation.negotiate(

        proposals_raw, world_state, lambda tied: tied[0]

    )



    # Step env with resolved proposals (neg_result.final_assignments already validated)

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



    # Compute per-agent rewards via RewardEngine

    per_agent_rewards = _reward_eng.compute_step_reward(

        neg_result.final_assignments, neg_result, world_state, list(AGENT_TYPES)

    )



    # Build conflict list from neg_result

    conflicts = [

        {

            "type":      str(c.conflict_type),

            "agents":    c.involved_agents,

            "sensor_id": c.involved_sensors[0] if c.involved_sensors else None,

            "target_id": c.involved_targets[0] if c.involved_targets else None,

        }

        for c in neg_result.conflicts_detected

    ]

    conflict_rate = _negotiation.get_conflict_rate()



    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 mx_obs.items()},

        "step_rewards":      step_rewards,

        "per_agent_rewards": per_agent_rewards,

        "done":              done,

        "info":              info,

        "conflict_rate":     round(conflict_rate, 4),

        "conflicts":         conflicts,

        "override_invoked":  neg_result.override_invoked,

    })





# ΓöÇΓöÇ UI ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ

@app.get("/")

@app.get("/ui")

def ui():

    return render_template("dashboard.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)