File size: 10,593 Bytes
a358495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Compositional intent grammar and allow-listed execution DAG.

Rules match concepts, synonyms and request operators, not full query strings.
Unknown requests abstain; a future language parser must emit this same schema.
"""
from enum import Enum
import re
from satquery_engine.schemas import PlanNode, TaskPlan, TaskType

Intent = Enum("Intent", {name: name for name in '''SINGLE_VQA SCENE_DESCRIPTION GROUNDING BUILDING_COUNT BUILDING_DETECTION BUILDING_FOOTPRINT BUILDING_GROUNDING BUILDING_CHANGE NEW_BUILDING_COUNT OBJECT_COUNT OBJECT_GROUNDING WATER_ANALYSIS WATER_GROUNDING WATER_AREA VEGETATION_ANALYSIS VEGETATION_CHANGE BUILT_UP_ANALYSIS BUILT_UP_CHANGE LAND_COVER_ANALYSIS CHANGE_DETECTION CHANGE_DESCRIPTION CHANGE_VQA CHANGE_AREA CHANGE_GROUNDING NEW_OBJECT_DETECTION REMOVED_OBJECT_DETECTION OPTICAL_SAR_ANALYSIS OPTICAL_SAR_COMPARISON OPTICAL_SAR_FUSION MULTI_INTENT UNSUPPORTED_TASK INVALID_REQUEST'''.split()}, type=str)
ALLOWED_TOOLS = {"validate", "register", "buildings", "building_match", "spectral", "spectral_change", "land_cover", "change", "fusion", "vlm", "synthesize", "report"}


def has(q, pattern):
    return bool(re.search(r"\b(?:" + pattern + r")\b", q))


def plan_query(query: str, image_count: int = 1, pair_type: str = "auto", configuration: str | None = None) -> TaskPlan:
    q = query.lower().strip().replace("-", " ")
    if not q:
        raise ValueError("Query cannot be empty")
    building = has(q, r"buildings?|houses?|homes?|structures?|rooftops?|footprints?")
    water = has(q, r"waters?|ndwi|rivers?|lakes?|ponds?|reservoirs?|oceans?|seas?|flood(?:s|ed|ing)?|canals?")
    veg = has(q, r"vegetation|forests?|trees?|crops?|greenery|canopy|ndvi|deforestation")
    built = has(q, r"built up|urban|construction|settlements?|cities|city|towns?|ndbi")
    other_object = has(q, r"roads?|bridges?|vehicles?|cars?|ships?|aircraft|airplanes?|airports?|containers?|tanks?")
    count = has(q, r"count|counting|how many|number|tally|total|enumerate")
    ground = has(q, r"where|find|locate|show|mark|highlight|outline|delineate|detect|identify|map")
    area = has(q, r"area|how much|size|extent|coverage|hectares|percentage|percent")
    change = has(q, r"chang(?:e|ed|es|ing)|increas(?:e|ed)|decreas(?:e|ed)|more|fewer|new|removed|disappeared|appeared|loss|lost|expanded|expansion|became|growth|grew|shrank|decreased|before|after|between|now")
    compare = has(q, r"compare|comparison|difference|different")
    fusion = has(q, r"sar|radar") and (has(q, r"optical|combine|fusion|fuse|together|compare|both") or image_count == 2)
    fusion = fusion or has(q, "both sensors|cross modal") or pair_type == "optical_sar"
    fusion = fusion or (compare and configuration in {"PAIR_OPTICAL_SAR", "PAIR_MULTISENSOR_BITEMPORAL"})
    change = (change or compare) and not fusion
    scene = has(q, r"describe|description|overview|summarize|caption|what is happening|what do you see|what can you see|what does .+ contain")
    land = has(q, r"land cover|landcover|land use|terrain|land types?|types? of land|land is visible|land")
    comprehensive = has(q,r"completely|comprehensive|everything|full analysis|analy[sz]e (?:this |the )?(?:image|scene) fully")
    if comprehensive:
        building=water=veg=built=scene=land=True
        count=True
        change=image_count==2 and configuration in {"PAIR_BITEMPORAL_OPTICAL","PAIR_BITEMPORAL_SAR"}
        fusion=configuration in {"PAIR_OPTICAL_SAR","PAIR_MULTISENSOR_BITEMPORAL"}
    if land and not change and not fusion:
        water = veg = True
    unsupported = has(q, r"capital of|president|who is|who was|write (?:a |some )?(?:code|script|poem)|python code|recipe|cook|translate|stock price|weather forecast|meaning of life")
    intents = []
    nodes = [PlanNode(node_id="validate", tool="validate")]
    def add_intent(value):
        if value not in intents:
            intents.append(value)
    def node(key, tool, deps=None, **params):
        nodes.append(PlanNode(node_id=key, tool=tool, depends_on=deps or ["validate"], parameters=params))
    if unsupported or not any([building, water, veg, built, scene, land, change, fusion, ground, other_object]):
        return TaskPlan(task=TaskType.UNSUPPORTED if unsupported else TaskType.UNCLEAR,
                        application="unsupported" if unsupported else "unclear", tools=[], reason="No supported remote-sensing request was established.",
                        intents=["UNSUPPORTED_TASK" if unsupported else "INVALID_REQUEST"],
                        clarification=None if unsupported else "What would you like to find out about the uploaded image?")
    if change:
        add_intent("CHANGE_DETECTION")
        node("register", "register")
    deps = ["register"] if change else ["validate"]
    if fusion:
        add_intent("OPTICAL_SAR_FUSION")
        node("register", "register")
        node("fusion", "fusion", ["register"], target="water" if water else None)
    elif building:
        add_intent("BUILDING_COUNT" if count else "BUILDING_DETECTION")
        if ground:
            add_intent("BUILDING_GROUNDING")
        if area:
            add_intent("BUILDING_FOOTPRINT")
        node("buildings_a", "buildings", deps, asset=0)
        if change:
            add_intent("BUILDING_CHANGE")
            if has(q, "new|appeared") and count:
                add_intent("NEW_BUILDING_COUNT")
            node("buildings_b", "buildings", deps, asset=1)
            node("building_match", "building_match", ["buildings_a", "buildings_b"])
    if not fusion:
        for present, target, base in [(water,"water","WATER"),(veg,"vegetation","VEGETATION"),(built,"built-up","BUILT_UP")]:
            if not present:
                continue
            if target == "built-up" and building:
                add_intent("BUILT_UP_CHANGE" if change else "BUILT_UP_ANALYSIS")
                continue  # Reuse measured building footprints, rather than run an unrelated index.
            add_intent(base + ("_CHANGE" if change and base != "WATER" else "_ANALYSIS"))
            if water and target == "water":
                if ground: add_intent("WATER_GROUNDING")
                if area: add_intent("WATER_AREA")
            node(target.replace("-", "_") + "_measure", "spectral_change" if change else "spectral", deps,
                 target=target, largest=has(q, "largest|biggest"), strict=has(q, "ndvi|ndwi|ndbi"))
        if change and not any([building, water, veg, built]):
            add_intent("CHANGE_AREA" if area else "CHANGE_DESCRIPTION")
            node("change", "change", deps)
        if land and not change:
            add_intent("LAND_COVER_ANALYSIS")
            node("land_cover", "land_cover", ["validate"])
        if scene or other_object or (ground and not any([building, water, veg, built, land, change])):
            add_intent("LAND_COVER_ANALYSIS" if land else "SCENE_DESCRIPTION" if scene else "OBJECT_COUNT" if count else "OBJECT_GROUNDING")
            node("scene", "vlm", deps, grounding=other_object or (ground and not (scene or land)), counting=count and other_object)
    leaves = [n.node_id for n in nodes if n.tool not in {"validate", "register"}]
    node("synthesize", "synthesize", leaves or ["validate"])
    node("report", "report", ["synthesize"])
    task = TaskType.OPTICAL_SAR if fusion else TaskType.BUILDING_CHANGE if change and building else TaskType.BI_TEMPORAL_CHANGE if change else TaskType.BUILDING_COUNT if building else TaskType.LAND_COVER if land else TaskType.WATER_ANALYSIS if water else TaskType.VEGETATION_ANALYSIS if veg else TaskType.BUILT_UP_ANALYSIS if built else TaskType.LAND_COVER if land else TaskType.SCENE_DESCRIPTION if scene else TaskType.OBJECT_IDENTIFICATION
    return TaskPlan(task=task, application="optical_sar" if fusion else "bi_temporal" if change else "single_image",
                    specific_task=task.value, intents=intents, sub_tasks=intents, multi_intent=len(intents)>1,
                    target="built-up" if building or built else "water" if water else "vegetation" if veg else None,
                    asks_direction=change, requires_pair=change or fusion, requires_temporal_relationship=change,
                    tools=list(dict.fromkeys(n.tool for n in nodes)), nodes=nodes,
                    reason="Composed requested objects, measurements and temporal/sensor relationships into a validated tool DAG.")


def validate_dag(plan):
    seen = set()
    tools = {}
    if plan.nodes and (plan.nodes[0].tool!="validate" or plan.nodes[0].depends_on):
        raise ValueError("Plan must validate inputs before executing any tool")
    parameter_keys={"validate":set(),"register":set(),"buildings":{"asset"},"building_match":set(),
        "spectral":{"target","largest","strict"},"spectral_change":{"target","largest","strict"},
        "change":set(),"land_cover":set(),"fusion":{"target"},"vlm":{"grounding","counting"},"synthesize":set(),"report":set()}
    for node in plan.nodes:
        if node.tool not in ALLOWED_TOOLS or node.node_id in seen or any(d not in seen for d in node.depends_on):
            raise ValueError("Invalid or non-allow-listed execution DAG")
        if set(node.parameters)-parameter_keys[node.tool]: raise ValueError("Unsupported tool parameters")
        if node.tool!="validate" and not node.depends_on: raise ValueError("Every tool requires validated input dependencies")
        if node.tool=="buildings" and (type(node.parameters.get("asset")) is not int or node.parameters["asset"] not in (0,1)):
            raise ValueError("Invalid building asset index")
        if node.tool in {"spectral","spectral_change"} and node.parameters.get("target") not in {"water","vegetation","built-up"}:
            raise ValueError("Unsupported spectral target")
        for key in ("largest","strict","grounding","counting"):
            if key in node.parameters and type(node.parameters[key]) is not bool: raise ValueError("Tool flags must be boolean")
        if node.tool in {"change","spectral_change","fusion"} and not any(tools.get(d)=="register" for d in node.depends_on):
            raise ValueError("Paired tools require registration")
        if node.tool=="building_match" and (len(node.depends_on)!=2 or any(tools.get(d)!="buildings" for d in node.depends_on)):
            raise ValueError("Building comparison requires two building outputs")
        if node.tool=="report" and not any(tools.get(d)=="synthesize" for d in node.depends_on):
            raise ValueError("Report requires canonical synthesis")
        seen.add(node.node_id)
        tools[node.node_id]=node.tool