File size: 5,654 Bytes
087643a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
142
143
144
145
146
147
148
# Decides if a question needs the detector at all. Two layers:
# 1. Cheap regex prefilter for the obvious cases (greetings, general
#    knowledge) - no point spending a Groq call on "what's the capital of
#    France".
# 2. LLM call (Groq, strict json_schema) for everything else. Routing
#    genuinely needs language understanding - a keyword list can't tell
#    "how many tables" from "is this page well organised".
#
# Key thing baked into the prompt: the model knows it's routing for a
# *layout* detector, not general vision. "What's the invoice total?"
# mentions the image but the detector can't read text - only a prompt that
# says so explicitly catches that and routes it out_of_scope.
from __future__ import annotations

import os
import re

from app.constants import CLASS_NAMES
from app.schemas import RouteDecision

# Kept short and specific on purpose - if a pattern is even slightly
# ambiguous it belongs with the LLM. A wrong prefilter call is worse than a
# slow one since it skips the LLM's judgement entirely.
_GREETING_PATTERN = re.compile(
    r"^\s*(hi|hello|hey|thanks|thank you)[\s!.,]*$", re.IGNORECASE
)
_META_PATTERN = re.compile(
    r"\b(what can you do|who are you|what are you|how do you work)\b", re.IGNORECASE
)
_GENERAL_KNOWLEDGE_PATTERN = re.compile(
    r"\b(capital of|president of|weather|what year is it|who won)\b", re.IGNORECASE
)


def prefilter(question: str) -> RouteDecision | None:
    """Only ever returns "definitely no detection needed", never "yes" -
    deciding detection IS needed requires actually understanding the
    sentence, which is the LLM's job. None means defer to it."""
    stripped = question.strip()

    if _GREETING_PATTERN.match(stripped):
        return RouteDecision(
            needs_detection=False, target_classes=[], task_type="out_of_scope",
            reason="Greeting, not a question about the image.", source="prefilter",
        )

    if _META_PATTERN.search(stripped) or _GENERAL_KNOWLEDGE_PATTERN.search(stripped):
        return RouteDecision(
            needs_detection=False, target_classes=[], task_type="out_of_scope",
            reason="General-knowledge or meta question, unrelated to image content.",
            source="prefilter",
        )

    return None


_ROUTER_SCHEMA = {
    "name": "route_decision",
    "strict": True,
    "schema": {
        "type": "object",
        "properties": {
            "needs_detection": {"type": "boolean"},
            "target_classes": {
                "type": "array",
                "items": {"type": "string", "enum": CLASS_NAMES},
            },
            "task_type": {
                "type": "string",
                "enum": ["count", "presence", "compare", "describe", "out_of_scope"],
            },
            "reason": {"type": "string"},
        },
        "required": ["needs_detection", "target_classes", "task_type", "reason"],
        "additionalProperties": False,
    },
}

_SYSTEM_PROMPT = f"""You route questions for a document layout detection API.

The detector can ONLY report the position and type of structural regions on a
document page. It knows WHERE things are, never WHAT they say. Its full
vocabulary of classes is exactly this list, nothing more:
{", ".join(CLASS_NAMES)}

Decide:
- needs_detection: does answering this actually require knowing what regions
  are on the page? Questions about text CONTENT the detector cannot read
  (amounts, names, dates, what a paragraph says, who signed something) are
  OUT OF SCOPE even though they mention the document - set needs_detection to
  false and task_type to "out_of_scope" for these. Do not guess at content the
  detector cannot see.
- target_classes: which of the classes above the question is actually about.
  Empty if needs_detection is false.
- task_type: "count" (how many), "presence" (is there a/any), "compare"
  (relationships between regions), "describe" (general layout question), or
  "out_of_scope".
- reason: one short sentence explaining the decision.
"""


def route(question: str, client=None) -> RouteDecision:
    """Prefilter first, then the LLM if it deferred. `client` is injectable
    for tests/pipeline to swap in a stub - see pipeline.py.

    Fails closed to out_of_scope on any LLM error (bad key, timeout, etc)
    rather than guessing detection is needed - skipping a real question is
    worse than declining an answerable one."""
    prefiltered = prefilter(question)
    if prefiltered is not None:
        return prefiltered

    if client is None:
        from groq import Groq

        client = Groq(api_key=os.environ.get("GROQ_API_KEY"))

    model = os.environ.get("GROQ_ROUTER_MODEL", "openai/gpt-oss-20b")

    try:
        response = client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": _SYSTEM_PROMPT},
                {"role": "user", "content": question},
            ],
            response_format={"type": "json_schema", "json_schema": _ROUTER_SCHEMA},
            temperature=0,
        )
        import json

        parsed = json.loads(response.choices[0].message.content)
        return RouteDecision(
            needs_detection=parsed["needs_detection"],
            target_classes=parsed["target_classes"],
            task_type=parsed["task_type"],
            reason=parsed["reason"],
            source="llm",
        )
    except Exception as error:
        return RouteDecision(
            needs_detection=False,
            target_classes=[],
            task_type="out_of_scope",
            reason=f"Router failed closed after an error: {error}",
            source="llm",
        )