SatQuery-api / backend /agent /followup.py
Sahil-nith's picture
Upload 47 files
0127c21 verified
Raw History Blame Contribute Delete
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