File size: 49,293 Bytes
0127c21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
"""
POST /api/query

Phase 4 (P0). Satisfies PS requirements:
- "interpret the query and classify the requested task"
- "check the number, modality, format, metadata, and compatibility"
- "select one or more models or tools from a predefined registry"
- "provide an auditable execution summary containing the selected
   task, model/tool names, and key parameters"

Phase 5 added: task == vqa runs a real model (models/vqa.py).
Phase 6 added: task == grounding runs a real model (models/grounding.py) β€”
the second mandatory single-image task (text-guided region grounding,
chosen over captioning β€” see PHASE6.md Β§1). Phase 7 added: change_detection
and change_vqa run real handlers (models/change.py β€” see PHASE7.md). Phase 8
added: optical_sar runs a real handler (models/optical_sar.py β€” see
PHASE8.md). All six task types are now live; nothing falls through to the
generic "pending" branch anymore, though it's kept below for any future
task type.

IMPORTANT β€” scope boundary (unchanged since Phase 4):
Routing/validation logic never fabricates results. tool_output is only
populated once a real specialist model actually ran for that request.
Each task's model call is isolated in its own handler function below so
adding Phase 7/8 doesn't grow a single unreadable branch.
"""
from __future__ import annotations

import time
from pathlib import Path
from typing import Literal, Optional

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from agent.registry import TASK_REGISTRY, TaskType
from agent.router import classify_query, validate_inputs
from agent.nlp_extract import extract_location_time
from agent.followup import followup_intent, infer_target_phrase, is_followup_query
from api.upload import IMAGE_PATHS, IMAGE_REGISTRY
from evidence.confidence_map import (
    from_answer_confidence,
    from_grounding_box,
    render_change_confidence_map,
    render_optical_sar_confidence_map,
)

router = APIRouter()


class SourceRef(BaseModel):
    """Phase 13: explicit source attribution for every image that fed a
    result, separate from the free-text `note` field. Answers "which
    uploaded file(s) is this evidence based on?" in a structured way."""

    image_id: str
    filename: str
    modality: str
    role: str


def _build_sources(images: list) -> list[dict]:
    sources = []
    for m in images:
        sources.append(
            SourceRef(
                image_id=m.image_id,
                filename=m.filename,
                modality=getattr(m.modality, "value", m.modality),
                role=getattr(m.role, "value", m.role),
            ).model_dump()
        )
    return sources


class QueryRequest(BaseModel):
    query: str
    image_ids: list[str]
    # Phase 15: optional follow-up context. The frontend sends back the
    # PREVIOUS turn's query/answer text so a short referential query
    # ("Where exactly?") can lean on it β€” see agent/followup.py. Nothing
    # is stored server-side; this is per-request only.
    previous_query: Optional[str] = None
    previous_answer: Optional[str] = None
    # Phase 16: hops 2 ("How much area?") and 3 ("Was this visible in
    # SAR?") need more than free text β€” they reuse the PREVIOUS turn's
    # grounding bounding box and the image_id it was computed on. Both
    # optional; if a follow-up intent needs them and they're missing,
    # the relevant handler below returns a clear "ask a full grounding
    # question first" message rather than guessing.
    previous_box: Optional[dict] = None  # {"x_min","y_min","x_max","y_max"}
    previous_image_id: Optional[str] = None


class InputCheck(BaseModel):
    valid: bool
    issues: list[str]


class VQAOutput(BaseModel):
    answer: str
    confidence: float
    confidence_level: Literal["high", "medium", "insufficient"]
    confidence_note: str
    model_id: str
    adapted: bool
    latency_ms: int


class BoundingBoxOut(BaseModel):
    x_min: float
    y_min: float
    x_max: float
    y_max: float


class GroundingOutput(BaseModel):
    phrase: str
    found: bool
    box: Optional[BoundingBoxOut]
    score: float
    confidence_level: Literal["high", "medium", "insufficient"]
    confidence_note: str
    image_width: int
    image_height: int
    model_id: str
    adapted: bool
    latency_ms: int
    # Phase 16: which uploaded image this box was actually computed on
    # (optical or SAR β€” the Evidence Sufficiency Engine may have picked
    # either). Lets the frontend pass the right image_id back for
    # follow-up hop 2 (area estimate needs that image's georeferencing).
    image_id: Optional[str] = None


class ChangeBoxOut(BaseModel):
    x_min: float
    y_min: float
    x_max: float
    y_max: float


class ChangeMapOut(BaseModel):
    changed_pixel_pct: float
    change_level: Literal["high", "medium", "low"]
    change_note: str
    largest_change_box: Optional[ChangeBoxOut]
    width: int
    height: int
    method: str


class ChangeDetectionOutput(BaseModel):
    description: str
    change_map: ChangeMapOut


class ChangeVQAOutput(BaseModel):
    answer: str
    change_map: ChangeMapOut
    vqa_confidence: float
    vqa_confidence_level: Literal["high", "medium", "insufficient"]
    model_id: str
    adapted: bool


class VerificationOut(BaseModel):
    """Phase 15: second, independently-computed deterministic check β€”
    see evidence/verification.py's docstring for the honesty contract on
    what this is and is not (NOT a second trained model)."""

    method_a: str
    method_a_level: str
    method_b: str
    method_b_level: str
    agree: bool
    note: str


class AreaEstimateOutput(BaseModel):
    """Phase 16, follow-up hop 2 β€” see evidence/area.py's honesty
    contract. area_km2 is None whenever real georeferencing isn't
    available; area_note always explains why in that case."""

    target_phrase: str
    box: BoundingBoxOut
    area_km2: Optional[float]
    area_note: str
    method: str


class OpticalSarOutput(BaseModel):
    target: Literal["water", "built_up"]
    answer: str
    agreement_pct: float
    optical_only_pct: float
    sar_only_pct: float
    confidence_level: Literal["high", "medium", "low"]
    width: int
    height: int
    method: str


class QueryResponse(BaseModel):
    query: str
    task: TaskType
    task_label: str
    ps_requirement: str
    classification_reason: str
    images_considered: list[str]
    input_check: InputCheck
    selected_tool: str
    status: Literal["routed", "blocked", "insufficient_evidence", "conflicting_evidence"]
    execution_trace: list[str]
    tool_output: Optional[dict] = None
    note: str
    # Phase 13 additions:
    extracted_locations: list[str] = []
    extracted_time_expressions: list[str] = []
    nlp_extraction_note: str = ""
    sources: list[dict] = []


class _ToolRunResult:
    """Internal β€” carries tool_output + note + trace lines back to run_query."""

    def __init__(
        self,
        tool_output: Optional[dict],
        note: str,
        extra_trace: list[str],
        status_override: Optional[str] = None,
    ):
        self.tool_output = tool_output
        self.note = note
        self.extra_trace = extra_trace
        self.status_override = status_override


def _missing_file_result(task_label: str) -> _ToolRunResult:
    return _ToolRunResult(
        tool_output=None,
        note=(
            f"{task_label} was selected but the uploaded file could not be found on disk "
            "(likely a backend restart cleared the in-memory registry but not this "
            "reference, or the file was deleted). Re-upload the image and retry."
        ),
        extra_trace=["Tool Execution: failed β€” source image file missing from disk"],
    )


def _optical_sar_pair(images: list) -> tuple[Optional[Path], Optional[Path]]:
    """Phase 9: pick the single-role optical image and single-role SAR
    image out of whatever was supplied (there may be 1 or 2). Used by the
    Evidence Sufficiency Engine to decide which one to actually run the
    model on."""
    optical_meta = next((m for m in images if getattr(m.modality, "value", m.modality) == "optical"), None)
    sar_meta = next((m for m in images if getattr(m.modality, "value", m.modality) == "sar"), None)
    optical_path = IMAGE_PATHS.get(optical_meta.image_id) if optical_meta else None
    sar_path = IMAGE_PATHS.get(sar_meta.image_id) if sar_meta else None
    return optical_path, sar_path


def _optical_sar_ids(images: list) -> tuple[Optional[str], Optional[str]]:
    """Phase 16: companion to _optical_sar_pair() that returns image_ids
    instead of paths β€” needed so a grounding response can tell the
    frontend WHICH uploaded image its box was actually computed on
    (the Evidence Sufficiency Engine may have picked either optical or
    SAR), so follow-up hop 2 (area estimate) knows which image's
    georeferencing to use."""
    optical_meta = next((m for m in images if getattr(m.modality, "value", m.modality) == "optical"), None)
    sar_meta = next((m for m in images if getattr(m.modality, "value", m.modality) == "sar"), None)
    return (
        optical_meta.image_id if optical_meta else None,
        sar_meta.image_id if sar_meta else None,
    )


def _run_vqa(images: list, req: "QueryRequest") -> _ToolRunResult:
    from evidence.sufficiency import decide_evidence  # deferred import

    optical_path, sar_path = _optical_sar_pair(images)
    if optical_path is None and sar_path is None:
        return _missing_file_result("VQA")

    try:
        decision = decide_evidence(optical_path, sar_path)
    except ValueError:
        return _missing_file_result("VQA")

    if decision.decision == "unsupported":
        return _ToolRunResult(
            tool_output=None,
            note=(
                f"Optical evidence insufficient (cloud coverage ~{decision.cloud_pct}%) and no "
                "complementary SAR image was supplied this session. SatQuery does not guess in "
                "this case β€” upload a SAR image of the same area, or provide a clearer optical image."
            ),
            extra_trace=decision.trace + ["Tool Execution: skipped β€” insufficient evidence, no SAR fallback available"],
            status_override="insufficient_evidence",
        )

    image_path = sar_path if decision.selected_modality == "sar" else optical_path
    if image_path is None or not Path(image_path).exists():
        return _missing_file_result("VQA")

    from models.vqa import run_vqa  # deferred: heavy import only when actually needed

    trace = list(decision.trace)
    trace.append("Tool Execution: running VQA specialist model…")
    start = time.perf_counter()
    try:
        result = run_vqa(Path(image_path), req.query)
    except Exception as exc:  # noqa: BLE001 β€” never let a model crash take down the API
        trace.append(f"Tool Execution: failed β€” {exc}")
        return _ToolRunResult(
            tool_output=None,
            note=(
                "VQA model invocation raised an error. Check backend logs; the most "
                "common hackathon-week cause is missing torch/transformers/pillow "
                "dependencies β€” see backend/requirements.txt."
            ),
            extra_trace=trace,
        )

    latency_ms = int((time.perf_counter() - start) * 1000)
    trace.append(f"Evidence Trace: model answer generated ({latency_ms} ms)")
    trace.append(
        f"Evidence Trace: confidence {result.confidence:.2f} "
        f"({result.confidence_level.upper()}) β€” {result.confidence_note}"
    )
    if decision.decision == "insufficient_no_fallback":
        trace.append(
            "Evidence Trace: WARNING β€” this answer was generated from optical evidence already "
            "flagged insufficient, with no SAR fallback available; treat it as unreliable"
        )
    trace.append(
        "Evidence Fusion: n/a β€” single-model, single-image task; "
        "fusion applies once optical+SAR (Phase 8) or multi-model "
        "verification is in play"
    )
    tool_output = VQAOutput(
        answer=result.answer,
        confidence=round(result.confidence, 4),
        confidence_level=result.confidence_level,
        confidence_note=result.confidence_note,
        model_id=result.model_id,
        adapted=result.adapted,
        latency_ms=latency_ms,
    ).model_dump()
    cm = from_answer_confidence(128, 128, result.confidence, result.confidence_level)
    tool_output["confidence_map_png"] = cm.png_base64
    tool_output["confidence_map_kind"] = cm.kind
    tool_output["confidence_map_note"] = cm.note
    note = (
        "VQA answer + confidence come from a live model call (Salesforce/blip-vqa-base"
        + (" + BigEarthNet LoRA adapter" if result.adapted else ", NOT yet domain-adapted β€” base weights only")
        + "). Confidence is a decoding-confidence proxy, not a validated "
        "benchmark accuracy figure β€” do not present it as such to judges."
    )
    if decision.selected_modality == "sar":
        note = (
            f"Optical evidence was insufficient (cloud coverage ~{decision.cloud_pct}%) β€” this "
            "answer was generated from the SAR image instead. " + note
        )
    elif decision.decision == "insufficient_no_fallback":
        note = (
            f"Optical evidence is insufficient (cloud coverage ~{decision.cloud_pct}%) and no SAR "
            "image was available to switch to β€” this answer is from cloudy optical imagery and "
            "should be treated as unreliable. " + note
        )
    return _ToolRunResult(tool_output=tool_output, note=note, extra_trace=trace)


def _run_grounding(images: list, req: "QueryRequest") -> _ToolRunResult:
    from evidence.sufficiency import decide_evidence  # deferred import

    optical_path, sar_path = _optical_sar_pair(images)
    if optical_path is None and sar_path is None:
        return _missing_file_result("Grounding")

    try:
        decision = decide_evidence(optical_path, sar_path)
    except ValueError:
        return _missing_file_result("Grounding")

    if decision.decision == "unsupported":
        return _ToolRunResult(
            tool_output=None,
            note=(
                f"Optical evidence insufficient (cloud coverage ~{decision.cloud_pct}%) and no "
                "complementary SAR image was supplied this session. SatQuery does not guess in "
                "this case β€” upload a SAR image of the same area, or provide a clearer optical image."
            ),
            extra_trace=decision.trace + ["Tool Execution: skipped β€” insufficient evidence, no SAR fallback available"],
            status_override="insufficient_evidence",
        )

    image_path = sar_path if decision.selected_modality == "sar" else optical_path
    if image_path is None or not Path(image_path).exists():
        return _missing_file_result("Grounding")

    from models.grounding import extract_referring_phrase, run_grounding  # deferred import

    trace = list(decision.trace)

    # Phase 15: follow-up handling. A short referential query like "Where
    # exactly?" extracts to a near-useless literal phrase on its own β€” if
    # it looks like a follow-up and the frontend supplied the previous
    # turn's answer text, pull a concrete target phrase out of THAT answer
    # instead. See agent/followup.py for exactly what this is/isn't.
    effective_query = req.query
    if is_followup_query(req.query) and req.previous_answer:
        inferred = infer_target_phrase(req.previous_answer)
        if inferred:
            effective_query = f"highlight the {inferred}"
            trace.append(
                f'Follow-up Context: "{req.query}" read as a follow-up to the previous answer β€” '
                f'inferred target phrase "{inferred}" from it (not a memory/dialogue model β€” see '
                "agent/followup.py)"
            )

    phrase = extract_referring_phrase(effective_query)
    trace.append(f'Decision Trace: extracted referring phrase "{phrase}" from query')
    trace.append("Tool Execution: running grounding specialist model…")
    start = time.perf_counter()
    try:
        result = run_grounding(Path(image_path), effective_query)
    except Exception as exc:  # noqa: BLE001
        trace.append(f"Tool Execution: failed β€” {exc}")
        return _ToolRunResult(
            tool_output=None,
            note=(
                "Grounding model invocation raised an error. Check backend logs; the most "
                "common hackathon-week cause is missing torch/transformers/pillow "
                "dependencies β€” see backend/requirements.txt."
            ),
            extra_trace=trace,
        )

    latency_ms = int((time.perf_counter() - start) * 1000)
    if result.found:
        trace.append(f"Evidence Trace: region located ({latency_ms} ms)")
        trace.append(
            f"Evidence Trace: confidence {result.score:.2f} "
            f"({result.confidence_level.upper()}) β€” {result.confidence_note}"
        )
    else:
        trace.append(f"Evidence Trace: no matching region found ({latency_ms} ms) β€” {result.confidence_note}")
    if decision.decision == "insufficient_no_fallback":
        trace.append(
            "Evidence Trace: WARNING β€” this result was generated from optical evidence already "
            "flagged insufficient, with no SAR fallback available; treat it as unreliable"
        )
    trace.append(
        "Evidence Fusion: n/a β€” single-model, single-image task; "
        "fusion applies once optical+SAR (Phase 8) or multi-model verification is in play"
    )

    optical_id, sar_id = _optical_sar_ids(images)
    used_image_id = sar_id if decision.selected_modality == "sar" else optical_id

    box_out = BoundingBoxOut(**result.box.__dict__) if result.box else None
    tool_output = GroundingOutput(
        phrase=result.phrase,
        found=result.found,
        box=box_out,
        score=round(result.score, 4),
        confidence_level=result.confidence_level,
        confidence_note=result.confidence_note,
        image_width=result.image_width,
        image_height=result.image_height,
        model_id=result.model_id,
        adapted=result.adapted,
        latency_ms=latency_ms,
        image_id=used_image_id,
    ).model_dump()
    box_tuple = (result.box.x_min, result.box.y_min, result.box.x_max, result.box.y_max) if result.box else None
    cm = from_grounding_box(result.image_width, result.image_height, box_tuple, result.score)
    tool_output["confidence_map_png"] = cm.png_base64
    tool_output["confidence_map_kind"] = cm.kind
    tool_output["confidence_map_note"] = cm.note
    note = (
        f"Grounding box + score come from a live model call (google/owlvit-base-patch32"
        + (" + RS LoRA adapter" if result.adapted else ", NOT yet domain-adapted β€” base weights only")
        + f') on the extracted phrase "{phrase}". No map overlay yet β€” that lands in Phase 10; '
        "box coordinates are pixel coordinates on the original uploaded image. Score is the "
        "detector's own match confidence, not a validated grounding-accuracy benchmark figure."
    )
    if decision.selected_modality == "sar":
        note = (
            f"Optical evidence was insufficient (cloud coverage ~{decision.cloud_pct}%) β€” this "
            "result was generated from the SAR image instead. " + note
        )
    elif decision.decision == "insufficient_no_fallback":
        note = (
            f"Optical evidence is insufficient (cloud coverage ~{decision.cloud_pct}%) and no SAR "
            "image was available to switch to β€” this result is from cloudy optical imagery and "
            "should be treated as unreliable. " + note
        )
    return _ToolRunResult(tool_output=tool_output, note=note, extra_trace=trace)


def _run_area_followup(images: list, req: "QueryRequest") -> _ToolRunResult:
    """Phase 16, follow-up hop 2 ("How much area?"). Pure geometry over
    the PREVIOUS turn's grounding box β€” does not call any model. See
    evidence/area.py's honesty contract for exactly when a real area
    figure is/isn't produced."""
    if not req.previous_box or not req.previous_image_id:
        return _ToolRunResult(
            tool_output=None,
            note=(
                "This reads as an area follow-up (\"how much area?\"), but no previous "
                "grounding result was supplied to measure. Ask a full grounding question "
                'first (e.g. "highlight the water body"), then "how much area?".'
            ),
            extra_trace=[
                "Follow-up Context: area follow-up detected but no previous_box/"
                "previous_image_id in request β€” nothing to measure"
            ],
            status_override="blocked",
        )

    meta = IMAGE_REGISTRY.get(req.previous_image_id)
    if meta is None:
        return _ToolRunResult(
            tool_output=None,
            note=(
                "The image the previous grounding box was computed on is no longer known "
                "to this backend session (likely a restart). Re-run the grounding question, "
                "then ask for the area again."
            ),
            extra_trace=["Follow-up Context: previous_image_id not found in IMAGE_REGISTRY"],
            status_override="blocked",
        )

    required_keys = {"x_min", "y_min", "x_max", "y_max"}
    if not required_keys.issubset(req.previous_box.keys()):
        return _ToolRunResult(
            tool_output=None,
            note="previous_box was missing one or more of x_min/y_min/x_max/y_max β€” cannot compute area.",
            extra_trace=["Follow-up Context: malformed previous_box in request"],
            status_override="blocked",
        )

    from evidence.area import estimate_box_area_km2  # deferred import

    box = req.previous_box
    result = estimate_box_area_km2(
        (box["x_min"], box["y_min"], box["x_max"], box["y_max"]),
        meta.width,
        meta.height,
        meta.crs,
        meta.bounds,
    )
    phrase = infer_target_phrase(req.previous_answer or "") or "the previously grounded region"

    trace = [
        f'Follow-up Context: "{req.query}" read as an area follow-up to the previous grounding '
        f"result on image {req.previous_image_id} (agent/followup.py) β€” reusing its bounding box, "
        "not re-running detection",
        "Tool Execution: computing area from bounding box + source image georeferencing",
        f"Evidence Trace: {result.area_note}",
    ]

    tool_output = AreaEstimateOutput(
        target_phrase=phrase,
        box=BoundingBoxOut(**box),
        area_km2=result.area_km2,
        area_note=result.area_note,
        method=result.method,
    ).model_dump()

    note = (
        "Area estimate is a geometric calculation over the previous grounding box's pixel "
        "footprint β€” not a new model call, and not a segmentation of the actual object. "
        + result.area_note
    )
    return _ToolRunResult(tool_output=tool_output, note=note, extra_trace=trace)


def _run_sar_check_followup(images: list, req: "QueryRequest") -> _ToolRunResult:
    """Phase 16, follow-up hop 3 ("Was this visible in SAR?"). Re-runs
    the SAME grounding model on the SAR image supplied THIS session for
    whatever phrase the previous turn's answer implies. This is an
    independent detector call on different pixels β€” it does not fuse or
    jointly reason over optical+SAR the way the dedicated optical_sar
    task (Phase 8) does."""
    sar_meta = next((m for m in images if getattr(m.modality, "value", m.modality) == "sar"), None)
    if sar_meta is None:
        return _ToolRunResult(
            tool_output=None,
            note=(
                "This reads as a SAR cross-check follow-up, but no SAR image was supplied in "
                "this request β€” upload a SAR image of the same area (Optical/SAR slots, not "
                "Before/After) to cross-check."
            ),
            extra_trace=["Follow-up Context: SAR cross-check requested but no SAR image among image_ids"],
            status_override="blocked",
        )

    sar_path = IMAGE_PATHS.get(sar_meta.image_id)
    if sar_path is None or not Path(sar_path).exists():
        return _missing_file_result("SAR cross-check")

    phrase = infer_target_phrase(req.previous_answer or "")
    if not phrase:
        return _ToolRunResult(
            tool_output=None,
            note=(
                "Could not determine what to look for in the SAR image from the previous "
                "answer's text β€” ask a fuller follow-up, e.g. \"was the flooded area visible "
                'in SAR?".'
            ),
            extra_trace=[
                "Follow-up Context: SAR cross-check requested but no target phrase could be "
                "inferred from previous_answer"
            ],
            status_override="blocked",
        )

    from models.grounding import run_grounding  # deferred import

    trace = [
        f'Follow-up Context: "{req.query}" read as a SAR cross-check follow-up β€” re-running '
        f'grounding for "{phrase}" against the SAR image instead of the original optical image '
        "(agent/followup.py)",
        "Tool Execution: running grounding specialist model on SAR image…",
    ]
    start = time.perf_counter()
    try:
        result = run_grounding(Path(sar_path), f"highlight the {phrase}")
    except Exception as exc:  # noqa: BLE001
        trace.append(f"Tool Execution: failed β€” {exc}")
        return _ToolRunResult(
            tool_output=None,
            note="Grounding model invocation on the SAR image raised an error. Check backend logs.",
            extra_trace=trace,
        )

    latency_ms = int((time.perf_counter() - start) * 1000)
    if result.found:
        trace.append(
            f"Evidence Trace: region located in SAR ({latency_ms} ms), confidence "
            f"{result.score:.2f} ({result.confidence_level.upper()})"
        )
    else:
        trace.append(f"Evidence Trace: no matching region found in SAR ({latency_ms} ms) β€” {result.confidence_note}")
    trace.append(
        "Evidence Fusion: n/a β€” this is an independent detector call on the SAR image, not a "
        "joint optical+SAR fusion result; see the dedicated Optical+SAR task (Phase 8) for that."
    )

    box_out = BoundingBoxOut(**result.box.__dict__) if result.box else None
    tool_output = GroundingOutput(
        phrase=result.phrase,
        found=result.found,
        box=box_out,
        score=round(result.score, 4),
        confidence_level=result.confidence_level,
        confidence_note=result.confidence_note,
        image_width=result.image_width,
        image_height=result.image_height,
        model_id=result.model_id,
        adapted=result.adapted,
        latency_ms=latency_ms,
        image_id=sar_meta.image_id,
    ).model_dump()
    box_tuple = (result.box.x_min, result.box.y_min, result.box.x_max, result.box.y_max) if result.box else None
    cm = from_grounding_box(result.image_width, result.image_height, box_tuple, result.score)
    tool_output["confidence_map_png"] = cm.png_base64
    tool_output["confidence_map_kind"] = cm.kind
    tool_output["confidence_map_note"] = cm.note

    note = (
        (f'Also visible in SAR (score {result.score:.2f}). ' if result.found else "Not detected in SAR. ")
        + f'Same OWL-ViT grounding model (google/owlvit-base-patch32) re-run on the SAR image for "{phrase}" β€” '
        "an independent detector call on different pixels, not a claim that optical and SAR were "
        "jointly verified by a fusion model."
    )
    return _ToolRunResult(tool_output=tool_output, note=note, extra_trace=trace)


def _before_after_paths(images: list) -> tuple[Optional[Path], Optional[Path]]:
    """Optical-only Before/After lookup β€” kept for any caller that only
    cares about the optical pair."""
    before = next((m for m in images if getattr(m.role, "value", m.role) == "before"), None)
    after = next((m for m in images if getattr(m.role, "value", m.role) == "after"), None)
    before_path = IMAGE_PATHS.get(before.image_id) if before else None
    after_path = IMAGE_PATHS.get(after.image_id) if after else None
    return before_path, after_path


def _temporal_paths_by_modality(
    images: list,
) -> tuple[Optional[Path], Optional[Path], Optional[Path], Optional[Path]]:
    """Phase 12: split whatever Before/After images were supplied by
    modality, so the Evidence Sufficiency Engine can compare an optical
    pair against a SAR pair. Returns
    (optical_before, optical_after, sar_before, sar_after)."""

    def _find(role: str, modality: str) -> Optional[Path]:
        meta = next(
            (
                m
                for m in images
                if getattr(m.role, "value", m.role) == role
                and getattr(m.modality, "value", m.modality) == modality
            ),
            None,
        )
        return IMAGE_PATHS.get(meta.image_id) if meta else None

    return (
        _find("before", "optical"),
        _find("after", "optical"),
        _find("before", "sar"),
        _find("after", "sar"),
    )


def _run_change_detection(images: list, req: "QueryRequest") -> _ToolRunResult:
    from evidence.sufficiency import decide_temporal_evidence  # deferred import (Phase 11/12)

    opt_before, opt_after, sar_before, sar_after = _temporal_paths_by_modality(images)
    if opt_before is None or opt_after is None:
        return _missing_file_result("Change Detection")

    decision = decide_temporal_evidence(opt_before, opt_after, sar_before, sar_after)

    if decision.decision == "unsupported":
        return _ToolRunResult(
            tool_output=None,
            note=(
                f"Cloud coverage reaches ~{decision.worst_cloud_pct}% on at least one of the "
                "Before/After images, and there is no complete complementary SAR before/after pair "
                "to fall back on. SatQuery does not guess a change result in this case β€” upload a "
                "clearer pair, or a co-registered SAR Before AND After pair for the same dates."
            ),
            extra_trace=decision.trace + ["Tool Execution: skipped β€” insufficient temporal evidence, no SAR fallback available"],
            status_override="insufficient_evidence",
        )

    before_path, after_path = (
        (sar_before, sar_after) if decision.selected_modality == "sar" else (opt_before, opt_after)
    )
    if before_path is None or after_path is None:
        return _missing_file_result("Change Detection")

    from models.change import run_change_detection  # deferred import

    trace = list(decision.trace)
    trace.append("Tool Execution: running change-detection specialist (pixel-diff heuristic)…")
    start = time.perf_counter()
    try:
        result = run_change_detection(before_path, after_path, req.query)
    except Exception as exc:  # noqa: BLE001
        trace.append(f"Tool Execution: failed β€” {exc}")
        return _ToolRunResult(
            tool_output=None,
            note="Change-detection invocation raised an error. Check backend logs β€” see backend/requirements.txt.",
            extra_trace=trace,
        )
    latency_ms = int((time.perf_counter() - start) * 1000)
    cm = result["change_map"]
    trace.append(f"Evidence Trace: change map computed ({latency_ms} ms)")
    trace.append(f"Evidence Trace: {cm['changed_pixel_pct']}% pixels changed ({cm['change_level'].upper()})")
    if decision.decision == "insufficient_no_fallback":
        trace.append(
            "Evidence Trace: WARNING β€” this change result was computed from a pair with cloud "
            "coverage already flagged insufficient, with no SAR fallback available; treat it as unreliable"
        )
    trace.append("Evidence Fusion: n/a β€” single deterministic heuristic over the pair, no separate models to fuse")

    from evidence.verification import verify_change  # deferred import (Phase 15)

    verification = verify_change(before_path, after_path, cm["change_level"], cm["changed_pixel_pct"])
    agree_ratio = "2/2" if verification.agree else "1/2"
    trace.append(f"Verification Trace: {agree_ratio} independent methods agree β€” {verification.note}")

    tool_output = ChangeDetectionOutput(
        description=result["description"],
        change_map=ChangeMapOut(**cm),
    ).model_dump()
    tool_output["verification"] = VerificationOut(
        method_a=verification.method_a,
        method_a_level=verification.method_a_level,
        method_b=verification.method_b,
        method_b_level=verification.method_b_level,
        agree=verification.agree,
        note=verification.note,
    ).model_dump()
    conf_map = render_change_confidence_map(before_path, after_path)
    tool_output["confidence_map_png"] = conf_map.png_base64
    tool_output["confidence_map_kind"] = conf_map.kind
    tool_output["confidence_map_note"] = conf_map.note
    note = (
        "Change map + description come from a deterministic pixel-difference heuristic "
        "(z-score threshold on grayscale intensity), NOT a trained change-detection network. "
        "See models/change.py for why, and the change_note field for caveats on this specific result."
    )
    if decision.decision == "insufficient_sar_activated":
        note = (
            f"Optical evidence was insufficient (cloud coverage up to ~{decision.worst_cloud_pct}%) β€” "
            "this change result was computed from the SAR before/after pair instead. " + note
        )
    elif decision.decision == "insufficient_no_fallback":
        note = (
            f"Cloud coverage is up to ~{decision.worst_cloud_pct}% on this Before/After pair and no "
            "SAR fallback was available β€” treat this change result as unreliable. " + note
        )
    return _ToolRunResult(tool_output=tool_output, note=note, extra_trace=trace)


def _run_change_vqa(images: list, req: "QueryRequest") -> _ToolRunResult:
    from evidence.sufficiency import decide_temporal_evidence  # deferred import (Phase 11/12)

    opt_before, opt_after, sar_before, sar_after = _temporal_paths_by_modality(images)
    if opt_before is None or opt_after is None:
        return _missing_file_result("Change VQA")

    decision = decide_temporal_evidence(opt_before, opt_after, sar_before, sar_after)

    if decision.decision == "unsupported":
        return _ToolRunResult(
            tool_output=None,
            note=(
                f"Cloud coverage reaches ~{decision.worst_cloud_pct}% on at least one of the "
                "Before/After images, and there is no complete complementary SAR before/after pair "
                "to fall back on. SatQuery does not guess a change-VQA answer in this case β€” upload "
                "a clearer pair, or a co-registered SAR Before AND After pair."
            ),
            extra_trace=decision.trace + ["Tool Execution: skipped β€” insufficient temporal evidence, no SAR fallback available"],
            status_override="insufficient_evidence",
        )

    before_path, after_path = (
        (sar_before, sar_after) if decision.selected_modality == "sar" else (opt_before, opt_after)
    )
    if before_path is None or after_path is None:
        return _missing_file_result("Change VQA")

    from models.change import run_change_vqa  # deferred import
    from evidence.fusion import check_change_conflict  # deferred import (Phase 12)

    trace = list(decision.trace)
    trace.append("Tool Execution: running change map (heuristic) + composite-image VQA…")
    start = time.perf_counter()
    try:
        result = run_change_vqa(before_path, after_path, req.query)
    except Exception as exc:  # noqa: BLE001
        trace.append(f"Tool Execution: failed β€” {exc}")
        return _ToolRunResult(
            tool_output=None,
            note="Change-VQA invocation raised an error. Check backend logs β€” see backend/requirements.txt.",
            extra_trace=trace,
        )
    latency_ms = int((time.perf_counter() - start) * 1000)
    trace.append(f"Evidence Trace: change map computed β€” {result.change_map.changed_pixel_pct}% pixels changed")
    trace.append(
        f"Evidence Trace: VQA answer generated ({latency_ms} ms), confidence "
        f"{result.vqa_confidence:.2f} ({result.vqa_confidence_level.upper()})"
    )
    if decision.decision == "insufficient_no_fallback":
        trace.append(
            "Evidence Trace: WARNING β€” this answer was generated from a pair with cloud coverage "
            "already flagged insufficient, with no SAR fallback available; treat it as unreliable"
        )
    trace.append(
        "Evidence Fusion: composite-image VQA answer presented alongside independent pixel-diff "
        "change stats β€” the two are shown together, not mathematically merged into one score"
    )

    # Phase 12: CONFLICTING evidence state β€” cross-check the deterministic
    # change_map magnitude against the free-text VQA answer's implied
    # direction. See evidence/fusion.py for exactly what this is/isn't.
    conflict = check_change_conflict(
        result.change_map.change_level, result.change_map.changed_pixel_pct, result.answer
    )
    if conflict.conflicting:
        trace.append(f"Verification Trace: CONFLICTING β€” {conflict.reason}")

    from evidence.verification import verify_change  # deferred import (Phase 15)

    verification = verify_change(
        before_path, after_path, result.change_map.change_level, result.change_map.changed_pixel_pct
    )
    agree_ratio = "2/2" if verification.agree else "1/2"
    trace.append(f"Verification Trace: {agree_ratio} independent methods agree β€” {verification.note}")

    cm_dict = result.change_map.__dict__ | {
        "largest_change_box": result.change_map.largest_change_box.__dict__
        if result.change_map.largest_change_box else None
    }
    tool_output = ChangeVQAOutput(
        answer=result.answer,
        change_map=ChangeMapOut(**cm_dict),
        vqa_confidence=result.vqa_confidence,
        vqa_confidence_level=result.vqa_confidence_level,
        model_id=result.model_id,
        adapted=result.adapted,
    ).model_dump()
    tool_output["verification"] = VerificationOut(
        method_a=verification.method_a,
        method_a_level=verification.method_a_level,
        method_b=verification.method_b,
        method_b_level=verification.method_b_level,
        agree=verification.agree,
        note=verification.note,
    ).model_dump()
    conf_map = render_change_confidence_map(before_path, after_path)
    tool_output["confidence_map_png"] = conf_map.png_base64
    tool_output["confidence_map_kind"] = conf_map.kind
    tool_output["confidence_map_note"] = conf_map.note
    note = (
        "Answer comes from feeding a single before|after composite image into the Phase 5 "
        "BLIP-VQA model β€” a heuristic bridge, NOT a purpose-built change-VQA (CDVQA) model. "
        "The independent pixel-diff change_map is included so judges can sanity-check the "
        "free-text answer against a deterministic signal. Do not present either as validated "
        "CDVQA benchmark accuracy."
    )
    if decision.decision == "insufficient_sar_activated":
        note = (
            f"Optical evidence was insufficient (cloud coverage up to ~{decision.worst_cloud_pct}%) β€” "
            "this answer was generated from the SAR before/after pair instead. " + note
        )
    elif decision.decision == "insufficient_no_fallback":
        note = (
            f"Cloud coverage is up to ~{decision.worst_cloud_pct}% on this Before/After pair and no "
            "SAR fallback was available β€” treat this answer as unreliable. " + note
        )
    status_override = None
    if conflict.conflicting:
        note = f"CONFLICTING EVIDENCE: {conflict.reason} " + note
        status_override = "conflicting_evidence"
    return _ToolRunResult(tool_output=tool_output, note=note, extra_trace=trace, status_override=status_override)


def _run_optical_sar(images: list, req: "QueryRequest") -> _ToolRunResult:
    optical_img = next((m for m in images if getattr(m.modality, "value", m.modality) == "optical"), None)
    sar_img = next((m for m in images if getattr(m.modality, "value", m.modality) == "sar"), None)
    optical_path = IMAGE_PATHS.get(optical_img.image_id) if optical_img else None
    sar_path = IMAGE_PATHS.get(sar_img.image_id) if sar_img else None
    if optical_path is None or sar_path is None or not optical_path.exists() or not sar_path.exists():
        return _missing_file_result("Optical-SAR Fusion")

    from models.optical_sar import run_optical_sar  # deferred import
    from evidence.fusion import check_optical_sar_conflict  # deferred import (Phase 12)

    trace = ["Tool Execution: running optical+SAR fusion (percentile-threshold agreement)…"]
    start = time.perf_counter()
    try:
        result = run_optical_sar(optical_path, sar_path, req.query)
    except Exception as exc:  # noqa: BLE001
        trace.append(f"Tool Execution: failed β€” {exc}")
        return _ToolRunResult(
            tool_output=None,
            note="Optical-SAR fusion invocation raised an error. Check backend logs β€” see backend/requirements.txt.",
            extra_trace=trace,
        )
    latency_ms = int((time.perf_counter() - start) * 1000)
    trace.append(f"Evidence Trace: optical evidence extracted β€” target '{result.target}'")
    trace.append(f"Evidence Trace: SAR evidence extracted β€” target '{result.target}'")
    trace.append(
        f"Evidence Fusion: {result.stats.agreement_pct}% agreement between modalities "
        f"({result.stats.confidence_level.upper()} confidence) ({latency_ms} ms)"
    )

    # Phase 12: CONFLICTING evidence state β€” flag when optical and SAR are
    # each independently flagging substantial, largely non-overlapping
    # area for the same target. See evidence/fusion.py for the exact rule.
    conflict = check_optical_sar_conflict(
        result.stats.agreement_pct, result.stats.optical_only_pct, result.stats.sar_only_pct
    )
    if conflict.conflicting:
        trace.append(f"Verification Trace: CONFLICTING β€” {conflict.reason}")

    from evidence.verification import verify_optical_sar  # deferred import (Phase 15)

    verification = verify_optical_sar(
        optical_path, sar_path, result.target, result.stats.agreement_pct, result.stats.confidence_level
    )
    agree_ratio = "2/2" if verification.agree else "1/2"
    trace.append(f"Verification Trace: {agree_ratio} independent methods agree β€” {verification.note}")

    tool_output = OpticalSarOutput(
        target=result.target,
        answer=result.answer,
        agreement_pct=result.stats.agreement_pct,
        optical_only_pct=result.stats.optical_only_pct,
        sar_only_pct=result.stats.sar_only_pct,
        confidence_level=result.stats.confidence_level,
        width=result.width,
        height=result.height,
        method=result.method,
    ).model_dump()
    tool_output["verification"] = VerificationOut(
        method_a=verification.method_a,
        method_a_level=verification.method_a_level,
        method_b=verification.method_b,
        method_b_level=verification.method_b_level,
        agree=verification.agree,
        note=verification.note,
    ).model_dump()
    conf_map = render_optical_sar_confidence_map(optical_path, sar_path, result.target)
    tool_output["confidence_map_png"] = conf_map.png_base64
    tool_output["confidence_map_kind"] = conf_map.kind
    tool_output["confidence_map_note"] = conf_map.note
    note = (
        "Fusion result comes from a deterministic percentile-threshold agreement heuristic "
        "(bright/dark optical + high/low SAR backscatter), NOT a trained multimodal fusion "
        "network and NOT radiometrically calibrated. See models/optical_sar.py for the full "
        "disclosure. This is the backend piece the Phase 9 Evidence Sufficiency Engine will "
        "call automatically when optical evidence is judged insufficient (cloud cover, etc.)."
    )
    status_override = None
    if conflict.conflicting:
        note = f"CONFLICTING EVIDENCE: {conflict.reason} " + note
        status_override = "conflicting_evidence"
    return _ToolRunResult(tool_output=tool_output, note=note, extra_trace=trace, status_override=status_override)


# Task -> handler. Only tasks with a real specialist model integrated
# appear here; anything else falls through to the generic "pending" path.
_TASK_HANDLERS = {
    TaskType.vqa: _run_vqa,
    TaskType.grounding: _run_grounding,
    TaskType.change_detection: _run_change_detection,
    TaskType.change_vqa: _run_change_vqa,
    TaskType.optical_sar: _run_optical_sar,
    # Phase 16: follow-up chain hops 2/3 β€” only ever dispatched to via
    # the followup_intent() override in run_query() below, never via
    # agent/router.py's keyword classifier.
    TaskType.area_estimate: _run_area_followup,
    TaskType.sar_cross_check: _run_sar_check_followup,
}


@router.post("/query", response_model=QueryResponse)
def run_query(req: QueryRequest) -> QueryResponse:
    if not req.query.strip():
        raise HTTPException(status_code=422, detail="Query text is required.")
    if not req.image_ids:
        raise HTTPException(status_code=422, detail="At least one image_id is required.")

    images = []
    missing = []
    for iid in req.image_ids:
        meta = IMAGE_REGISTRY.get(iid)
        if meta is None:
            missing.append(iid)
        else:
            images.append(meta)
    if missing:
        raise HTTPException(
            status_code=404,
            detail=f"Unknown image_id(s): {missing}. Upload via /api/upload first.",
        )

    # Phase 16: check follow-up intent BEFORE keyword classification.
    # This fixes a real Phase 15 bug (see agent/followup.py's module
    # docstring): classify_query()'s keyword list doesn't recognise bare
    # follow-up phrasing like "Where exactly?" as grounding intent, so
    # hop 1 would silently default to VQA and never reach the grounding
    # handler at all. Detecting intent here and setting the task
    # directly β€” instead of hoping keyword matching happens to agree β€”
    # makes all three follow-up hops reliably reachable.
    intent = followup_intent(req.query) if req.previous_answer else None
    if intent == "area":
        task, reason = TaskType.area_estimate, 'matched area follow-up pattern ("how much area?") on a previous answer'
    elif intent == "sar_check":
        task, reason = (
            TaskType.sar_cross_check,
            'matched SAR cross-check follow-up pattern ("was this visible in SAR?") on a previous answer',
        )
    elif intent == "locate":
        task, reason = (
            TaskType.grounding,
            'matched follow-up locate pattern ("where exactly?") on a previous answer β€” '
            "routed directly to grounding (agent/followup.py), bypassing keyword classification",
        )
    else:
        task, reason = classify_query(req.query)
    spec = TASK_REGISTRY[task]
    valid, issues = validate_inputs(task, images)
    nlp = extract_location_time(req.query)

    trace = [
        f'Query received: "{req.query}"',
        f"Decision Trace: classified as {spec['label']} ({reason})",
        f"NLP Extraction: locations={nlp.locations or '[]'} ({nlp.location_source}), "
        f"time={nlp.time_expressions or '[]'}",
        f"Input validation: {len(images)} image(s) supplied β€” {'OK' if valid else 'FAILED'}",
    ]

    tool_output: Optional[dict] = None
    status = "routed" if valid else "blocked"
    note = (
        f"{spec['label']} has no specialist model wired in yet β€” "
        "task classification + input validation + tool selection only. "
        "See README for when this task's model lands."
    )

    if not valid:
        for issue in issues:
            trace.append(f"  βœ— {issue}")
        trace.append("Tool Execution: blocked β€” input requirements not met")
    else:
        trace.append(f"Tool selected: {spec['tool']}")

        handler = _TASK_HANDLERS.get(task)
        if handler is not None:
            run_result = handler(images, req)
            trace.extend(run_result.extra_trace)
            tool_output = run_result.tool_output
            note = run_result.note
            if run_result.status_override:
                status = run_result.status_override
        else:
            trace.append(f"Tool Execution: pending β€” {spec['label']} model not yet integrated")

    return QueryResponse(
        query=req.query,
        task=task,
        task_label=spec["label"],
        ps_requirement=spec["ps_requirement"],
        classification_reason=reason,
        images_considered=[m.image_id for m in images],
        input_check=InputCheck(valid=valid, issues=issues),
        selected_tool=spec["tool"],
        status=status,
        execution_trace=trace,
        tool_output=tool_output,
        note=note,
        extracted_locations=nlp.locations,
        extracted_time_expressions=nlp.time_expressions,
        nlp_extraction_note=nlp.note,
        sources=_build_sources(images),
    )