Spaces:
Paused
Paused
Download backend/agent/router.py from Sahil-nith/SatQuery-api: direct link, hf CLI and curl.
- Browser
- Download file 5.34 kB
-
https://huggingface.co/spaces/Sahil-nith/SatQuery-api/resolve/main/backend/agent/router.py
- Command line
-
hf download hf://spaces/Sahil-nith/SatQuery-api/backend/agent/router.py
-
curl -L -o router.py https://huggingface.co/spaces/Sahil-nith/SatQuery-api/resolve/main/backend/agent/router.py
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 | |