Spaces:
Paused
Paused
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),
)
|