Spaces:
Paused
Paused
Download backend/agent/followup.py from Sahil-nith/SatQuery-api: direct link, hf CLI and curl.
- Browser
- Download file 6.66 kB
-
https://huggingface.co/spaces/Sahil-nith/SatQuery-api/resolve/main/backend/agent/followup.py
- Command line
-
hf download hf://spaces/Sahil-nith/SatQuery-api/backend/agent/followup.py
-
curl -L -o followup.py https://huggingface.co/spaces/Sahil-nith/SatQuery-api/resolve/main/backend/agent/followup.py
6.66 kB
| """ | |
| Follow-up query helpers β let a short, referential query ("Where exactly?", | |
| "How much area?", "Was this visible in SAR?") lean on the PREVIOUS turn's | |
| answer instead of forcing the user to retype the full context every time. | |
| Phase 15 built hop 1 (-> grounding). Phase 16 (this file, extended) adds | |
| the intent classifier that distinguishes all three hops from | |
| uisuggestions.docx's "Ask Follow-up" chain: | |
| "Has the built-up area increased?" -> change_vqa (turn 1) | |
| "Where exactly?" -> locate -> grounding (hop 1) | |
| "How much area?" -> area -> area calc (hop 2) | |
| "Was this visible in SAR?" -> sar_check -> grounding on SAR (hop 3) | |
| Phase 15 also shipped hop 1 with a real bug: agent/router.py's | |
| classify_query() only recognises "where is" (not bare "where exactly?" or | |
| "show it") as a grounding-intent keyword, so a literal "Where exactly?" | |
| follow-up would default to VQA and never reach the grounding handler at | |
| all β the phrase-inference code existed but was frequently unreachable. | |
| followup_intent() below is now checked BEFORE classify_query() in | |
| api/query.py's run_query() precisely to fix that: when a follow-up intent | |
| is detected, the task is set directly, bypassing keyword classification. | |
| HONESTY CONTRACT: | |
| This is NOT conversational memory, NOT a dialogue-state model, and NOT an | |
| LLM call. It is rule-based pattern matching over plain text the frontend | |
| already has (the previous turn's query + answer, and β for hops 2/3 β | |
| the previous turn's grounding box/image, which the frontend now also | |
| sends back) β nothing is persisted server-side beyond a single request. | |
| 1. followup_intent() β classifies which of the three hops (if any) a | |
| query looks like, purely from surface patterns. | |
| 2. is_followup_query() β the original Phase 15 catch-all heuristic, | |
| kept for backward compatibility with any caller still using it | |
| directly; followup_intent() is the richer, preferred entry point. | |
| 3. infer_target_phrase() β pulls a plausible short noun phrase out of | |
| the PREVIOUS answer text (checking the PS's own representative | |
| vocabulary first, then falling back to a generic heuristic) so a | |
| subsequent grounding call has something concrete to search for. | |
| All of these will sometimes guess wrong on unusual phrasing β that is an | |
| accepted trade-off for a rule-based prototype, consistent with | |
| agent/router.py and agent/nlp_extract.py's own "do NOT over-engineer the | |
| agent" approach. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from typing import Literal, Optional | |
| FollowupIntent = Literal["locate", "area", "sar_check"] | |
| # Checked in this order β most specific first β by followup_intent(). | |
| _SAR_CHECK_PATTERNS = [ | |
| r"^(and )?was (it|this|that) (visible|seen)( in| on)? (the )?(sar|radar)\??$", | |
| r"^(and )?(is (it|this) visible) (in|on) (the )?(sar|radar)\??$", | |
| r"^(and )?what about( the)? (sar|radar)\??$", | |
| r"^(and )?(does|did) (sar|radar) (show|confirm|see) (it|this|that)\??$", | |
| r"^(and )?(check|cross.?check) (this|it) (in|against|with) (the )?(sar|radar)\??$", | |
| ] | |
| _AREA_PATTERNS = [ | |
| r"^how much( area| space)?\??$", | |
| r"^how big( is it| is that)?\??$", | |
| r"^what('?s| is) the area\??$", | |
| r"^(and )?what('?s| is) the (approx(imate)?\.? )?(affected )?area\??$", | |
| r"^(and )?how many (km2|km\^?2|sq ?km|square k(ilo)?meters?)\??$", | |
| ] | |
| _FOLLOWUP_PATTERNS = [ | |
| r"^where( exactly)?\??$", | |
| r"^where is (it|that|this)\??$", | |
| r"^how much( area)?\??$", | |
| r"^how big( is it)?\??$", | |
| r"^show (it|that|me)\??$", | |
| r"^highlight (it|that)\??$", | |
| r"^(and )?was (it|this|that) (visible|seen)", | |
| r"^(and )?(what about|is (it|this) visible) (in|on) (the )?sar", | |
| ] | |
| # Very short queries that lean on pronouns/deixis are treated as follow-ups | |
| # even if they don't match a fixed pattern above. | |
| _MAX_STANDALONE_WORDS = 4 | |
| _DEICTIC_WORDS = ("it", "that", "this", "there", "exactly") | |
| def is_followup_query(query: str) -> bool: | |
| q = query.strip().lower() | |
| if not q: | |
| return False | |
| if any(re.match(p, q) for p in _FOLLOWUP_PATTERNS): | |
| return True | |
| words = q.rstrip("?.!").split() | |
| return len(words) <= _MAX_STANDALONE_WORDS and any(w in _DEICTIC_WORDS for w in words) | |
| def followup_intent(query: str) -> Optional[FollowupIntent]: | |
| """Classify which follow-up hop `query` looks like, or None if it | |
| doesn't read as a follow-up at all. Order matters: SAR-check and area | |
| patterns are checked first because they are strictly more specific | |
| than the generic "locate" catch-all (e.g. "how much area?" would | |
| otherwise also satisfy is_followup_query's short-deictic-query check). | |
| Callers should only trust this when a previous turn actually exists | |
| (i.e. `previous_answer` was supplied) β a bare "where exactly?" with | |
| no prior context is not a follow-up, it's just an underspecified | |
| question, and should fail normal validation instead of being routed | |
| here. | |
| """ | |
| q = query.strip().lower() | |
| if not q: | |
| return None | |
| if any(re.match(p, q) for p in _SAR_CHECK_PATTERNS): | |
| return "sar_check" | |
| if any(re.match(p, q) for p in _AREA_PATTERNS): | |
| return "area" | |
| if is_followup_query(q): | |
| return "locate" | |
| return None | |
| # PS-representative vocabulary checked first β matches the kind of nouns | |
| # the problem statement's own sample queries use ("built-up area", "water | |
| # body", etc.), so a real hero-demo answer resolves cleanly. | |
| _KNOWN_TARGETS = [ | |
| "built-up area", "built up area", "water body", "flooded area", | |
| "flooded surface", "flooding", "vegetation", "bare soil", | |
| "agricultural land", "urban area", "water", "cloud", | |
| ] | |
| _STOPWORDS = { | |
| "the", "a", "an", "is", "was", "were", "are", "in", "on", "at", "of", | |
| "this", "that", "these", "those", "and", "or", "to", "region", "area", | |
| "image", "scene", "between", "detected", "visible", "between", "date", | |
| "dates", "confidence", "high", "medium", "low", "note", | |
| } | |
| def infer_target_phrase(previous_answer: str) -> str | None: | |
| """Best-effort grounding phrase pulled from the previous turn's free-text | |
| answer. Returns None if nothing usable was found β callers should fall | |
| back to the literal (current) query text in that case, never guess | |
| silently past that point.""" | |
| if not previous_answer: | |
| return None | |
| a = previous_answer.lower() | |
| for target in _KNOWN_TARGETS: | |
| if target in a: | |
| return target | |
| words = [w.strip(".,;:()") for w in a.split()] | |
| words = [w for w in words if w and w not in _STOPWORDS] | |
| return " ".join(words[:3]) if words else None | |