SatQuery-api / backend /agent /router.py
Sahil-nith's picture
Upload 47 files
0127c21 verified
Raw History Blame Contribute Delete
5.34 kB
"""
Query/Agent router.
Phase 4 (P0). Satisfies PS requirements:
- "interpret the query and classify the requested task"
- "check the number, modality, format, metadata, and compatibility of
the input images"
Deterministic, rule-based classification on purpose — the brief and PS
both explicitly allow this ("The initial implementation may use
deterministic rules or a lightweight classifier... Do NOT over-engineer
the agent"). Swap in a lightweight LLM/classifier later ONLY if the
rules prove insufficient in testing; don't pre-optimize this.
classify_query() and validate_inputs() are pure functions — no I/O,
no model calls — so they're trivial to unit test and to reason about
during a live demo ("why did it pick this task?").
"""
from __future__ import annotations
from agent.registry import TASK_REGISTRY, TaskType
# Ordered rule groups — first match wins. Order matters: more specific
# multi-image intents are checked before generic single-image ones so
# e.g. "what changed" doesn't fall through to plain VQA.
_OPTICAL_SAR_KEYWORDS = [
"optical and sar", "sar and optical", "combine optical", "fuse",
"fusion", "using both", "both sensors", "radar and optical",
"optical, sar", "together to identify",
]
_CHANGE_KEYWORDS = [
"chang", "increase", "decrease", "before and after", "differ",
"compare these", "compared to", "over time",
]
_CHANGE_QUESTION_KEYWORDS = [
"what changed", "where did", "how much", "did the", "has the",
"was there a change",
]
_GROUNDING_KEYWORDS = [
"where is", "highlight", "locate", "point to", "show me the",
"draw a box", "mark the",
]
_CAPTIONING_KEYWORDS = [
"describe", "caption", "summariz", "what does this show",
"give a description", "overview of this image",
]
def classify_query(query: str) -> tuple[TaskType, str]:
"""Returns (task, human-readable reason). Reason is shown in the
execution trace — it's a classification justification, not hidden
model reasoning, so it's fine to surface."""
q = query.lower().strip()
if any(k in q for k in _OPTICAL_SAR_KEYWORDS):
return TaskType.optical_sar, "matched optical+SAR fusion keywords"
if any(k in q for k in _CHANGE_KEYWORDS):
if any(k in q for k in _CHANGE_QUESTION_KEYWORDS):
return TaskType.change_vqa, "matched change-question phrasing over a temporal pair"
return TaskType.change_detection, "matched change/temporal keywords"
if any(k in q for k in _GROUNDING_KEYWORDS):
return TaskType.grounding, "matched region-grounding keywords"
if any(k in q for k in _CAPTIONING_KEYWORDS):
return TaskType.captioning, "matched scene-description keywords"
return TaskType.vqa, "no stronger signal matched — defaulted to single-image VQA"
def validate_inputs(task: TaskType, images: list) -> tuple[bool, list[str]]:
"""images: list of UploadResponse-like objects with .modality/.role
attributes (duck-typed to avoid a circular import on api.upload)."""
spec = TASK_REGISTRY[task]
issues: list[str] = []
n = len(images)
if n < spec["min_images"] or n > spec["max_images"]:
if spec["min_images"] == spec["max_images"]:
issues.append(f"{spec['label']} requires exactly {spec['min_images']} image(s); got {n}.")
else:
issues.append(
f"{spec['label']} requires between {spec['min_images']} and {spec['max_images']} image(s); got {n}."
)
for img in images:
modality = getattr(img, "modality", None)
role = getattr(img, "role", None)
modality_val = getattr(modality, "value", modality)
role_val = getattr(role, "value", role)
if modality_val not in spec["allowed_modalities"]:
issues.append(f"Image {img.image_id}: modality '{modality_val}' not valid for {spec['label']}.")
if role_val not in spec["allowed_roles"]:
hint = (
" Hint: re-upload it under the Optical or SAR slot instead of Before/After."
if role_val in ("before", "after") and "single" in spec["allowed_roles"]
else (
" Hint: change tasks need one image in Before and one in After — not Optical/SAR."
if role_val == "single" and "single" not in spec["allowed_roles"]
else ""
)
)
issues.append(
f"Image {img.image_id}: role '{role_val}' not valid for {spec['label']} "
f"(expected one of {sorted(spec['allowed_roles'])}).{hint}"
)
if spec["required_modalities"] is not None:
present = {getattr(getattr(i, 'modality', None), 'value', getattr(i, 'modality', None)) for i in images}
missing = spec["required_modalities"] - present
if missing:
issues.append(f"{spec['label']} requires modalities {sorted(missing)} — not present in supplied images.")
if task in (TaskType.change_detection, TaskType.change_vqa) and n == 2:
roles_present = {getattr(getattr(i, 'role', None), 'value', getattr(i, 'role', None)) for i in images}
if roles_present != {"before", "after"}:
issues.append("Change tasks require exactly one 'before' image and one 'after' image.")
return (len(issues) == 0), issues