Spaces:
Sleeping
[fix] Give Ollama a real context window (root cause); deterministic weak-check
Browse filesRoot cause of the 'assessment acts like a chatbot / refuses' behavior: Ollama
loaded the model with its default ~4096-token context, which silently TRUNCATED
the assessment prompt (system + facts + injected guideline/country tool results)
and dropped the instructions — so the model drifted to a greeting. Fix the cause:
- agent/loop.py passes options.num_ctx (env NUM_CTX, default 16384) on every chat.
- deploy/modal_app.py loads the model with OLLAMA_CONTEXT_LENGTH=NUM_CTX so the
server matches the client (no reload on first request).
With 16k context lfm2.5:8b now produces a correct, structured assessment on its
own (verified over Modal: refugee · sexual-orientation PSG · high · 3 cards).
Per feedback, replace the brittle prose phrase-matching weak-detector with a
DETERMINISTIC signal: the answer is weak only when the model produced no
structured output (case_type/grounds/countries) or almost no text — no guessing
at non-deterministic wording. The interview-grounded fallback then rarely fires.
Co-authored-by: Codex <noreply@openai.com>
- agent/loop.py +6 -0
- app/phases/assessment.py +12 -23
- deploy/modal_app.py +5 -1
- tests/unit/test_assessment_logic.py +21 -14
|
@@ -94,6 +94,11 @@ class AgentLoop:
|
|
| 94 |
self.model_id = model_id or os.getenv("MODEL_ID", DEFAULT_MODEL_ID)
|
| 95 |
self.provider = os.getenv("MODEL_PROVIDER", "ollama")
|
| 96 |
self._host = host or os.getenv("OLLAMA_HOST")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
self._client = None # lazily created so import never needs a server
|
| 98 |
# Not all models accept the `think` parameter (e.g. qwen2.5:7b). We
|
| 99 |
# optimistically try it, then disable it for this session on first
|
|
@@ -152,6 +157,7 @@ class AgentLoop:
|
|
| 152 |
tools=tool_schemas,
|
| 153 |
stream=True,
|
| 154 |
think=attempt_think,
|
|
|
|
| 155 |
)
|
| 156 |
async for chunk in stream:
|
| 157 |
produced = True
|
|
|
|
| 94 |
self.model_id = model_id or os.getenv("MODEL_ID", DEFAULT_MODEL_ID)
|
| 95 |
self.provider = os.getenv("MODEL_PROVIDER", "ollama")
|
| 96 |
self._host = host or os.getenv("OLLAMA_HOST")
|
| 97 |
+
# Context window for Ollama. Its default is small (~4096); the assessment
|
| 98 |
+
# prompt (system + facts + injected guideline/country tool results) easily
|
| 99 |
+
# overflows that, which silently truncates the instructions and makes the
|
| 100 |
+
# model drift (e.g. answer as a chatbot). Give it real headroom.
|
| 101 |
+
self.num_ctx = int(os.getenv("NUM_CTX", "16384"))
|
| 102 |
self._client = None # lazily created so import never needs a server
|
| 103 |
# Not all models accept the `think` parameter (e.g. qwen2.5:7b). We
|
| 104 |
# optimistically try it, then disable it for this session on first
|
|
|
|
| 157 |
tools=tool_schemas,
|
| 158 |
stream=True,
|
| 159 |
think=attempt_think,
|
| 160 |
+
options={"num_ctx": self.num_ctx},
|
| 161 |
)
|
| 162 |
async for chunk in stream:
|
| 163 |
produced = True
|
|
@@ -161,29 +161,18 @@ _GROUND_MAP = {
|
|
| 161 |
}
|
| 162 |
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
# conversational greetings / requests for info it already has
|
| 175 |
-
"could you tell me", "can you tell me", "please tell me", "please share",
|
| 176 |
-
"please provide", "when you're ready", "when you are ready", "i'm here to help",
|
| 177 |
-
"i am here to help", "here to help you", "preferred language", "which language",
|
| 178 |
-
"navigate your options", "let me know", "feel free to", "happy to help",
|
| 179 |
-
)
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def _is_weak_reasoning(text: str) -> bool:
|
| 183 |
-
t = (text or "").strip().lower()
|
| 184 |
-
if len(t) < 120:
|
| 185 |
return True
|
| 186 |
-
return
|
| 187 |
|
| 188 |
|
| 189 |
def _derive_case(session: SessionState) -> tuple[str, list[str], str]:
|
|
@@ -402,7 +391,7 @@ async def stream_assessment(session: SessionState, loop):
|
|
| 402 |
# narration is *weak* (a refusal / "I cannot determine …" / barely anything),
|
| 403 |
# the interview-derived read takes over so the person never sees a non-answer.
|
| 404 |
case_d, grounds_d, risk_d = _derive_case(session)
|
| 405 |
-
weak = _is_weak_reasoning(visible)
|
| 406 |
case_type = case_d if weak else (result.case_type or case_d)
|
| 407 |
grounds_final = grounds_d if weak else (result.grounds or grounds_d)
|
| 408 |
risk_final = risk_d if weak else (result.risk or risk_d)
|
|
|
|
| 161 |
}
|
| 162 |
|
| 163 |
|
| 164 |
+
def _is_weak_reasoning(text: str, result) -> bool:
|
| 165 |
+
"""Deterministic test for a non-answer — no brittle prose pattern-matching.
|
| 166 |
+
|
| 167 |
+
The assessment is required to end with a structured block (case_type / grounds
|
| 168 |
+
/ countries). The model is weak/off-task when it produced **none** of that
|
| 169 |
+
structure, or barely any narration. When that happens (e.g. it drifted into a
|
| 170 |
+
chatbot greeting because the context window truncated its instructions), the
|
| 171 |
+
interview-grounded analysis takes over. With an adequate context window this
|
| 172 |
+
rarely fires."""
|
| 173 |
+
if len((text or "").strip()) < 120:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
return True
|
| 175 |
+
return not (result.case_type or result.grounds or result.countries)
|
| 176 |
|
| 177 |
|
| 178 |
def _derive_case(session: SessionState) -> tuple[str, list[str], str]:
|
|
|
|
| 391 |
# narration is *weak* (a refusal / "I cannot determine …" / barely anything),
|
| 392 |
# the interview-derived read takes over so the person never sees a non-answer.
|
| 393 |
case_d, grounds_d, risk_d = _derive_case(session)
|
| 394 |
+
weak = _is_weak_reasoning(visible, result)
|
| 395 |
case_type = case_d if weak else (result.case_type or case_d)
|
| 396 |
grounds_final = grounds_d if weak else (result.grounds or grounds_d)
|
| 397 |
risk_final = risk_d if weak else (result.risk or risk_d)
|
|
@@ -37,6 +37,10 @@ MODELS = [LLM_MODEL, EMBED_MODEL]
|
|
| 37 |
|
| 38 |
GPU = os.environ.get("MODAL_GPU", "L4") # L4 (cheapest) | A10G | A100
|
| 39 |
MIN_CONTAINERS = int(os.environ.get("MODAL_MIN_CONTAINERS", "0")) # 1 = keep warm
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
OLLAMA_DIR = "/root/.ollama" # model cache (Volume mount)
|
| 41 |
PORT = 11434
|
| 42 |
|
|
@@ -57,7 +61,7 @@ def _start_ollama(bind: str = "0.0.0.0", keep_alive: str | None = None) -> None:
|
|
| 57 |
|
| 58 |
``keep_alive="-1"`` tells Ollama never to unload the model from GPU while the
|
| 59 |
container is warm, so a kept-warm endpoint answers in ~1s with no reload."""
|
| 60 |
-
env = {**os.environ, "OLLAMA_HOST": f"{bind}:{PORT}"}
|
| 61 |
if keep_alive is not None:
|
| 62 |
env["OLLAMA_KEEP_ALIVE"] = keep_alive
|
| 63 |
subprocess.Popen(["ollama", "serve"], env=env)
|
|
|
|
| 37 |
|
| 38 |
GPU = os.environ.get("MODAL_GPU", "L4") # L4 (cheapest) | A10G | A100
|
| 39 |
MIN_CONTAINERS = int(os.environ.get("MODAL_MIN_CONTAINERS", "0")) # 1 = keep warm
|
| 40 |
+
# Context window. Ollama's default (~4096) truncates our assessment prompt; load
|
| 41 |
+
# the model at this size so it (and the app's per-request num_ctx) match -> no
|
| 42 |
+
# reload on the first request. Must match the app's NUM_CTX.
|
| 43 |
+
NUM_CTX = os.environ.get("NUM_CTX", "16384")
|
| 44 |
OLLAMA_DIR = "/root/.ollama" # model cache (Volume mount)
|
| 45 |
PORT = 11434
|
| 46 |
|
|
|
|
| 61 |
|
| 62 |
``keep_alive="-1"`` tells Ollama never to unload the model from GPU while the
|
| 63 |
container is warm, so a kept-warm endpoint answers in ~1s with no reload."""
|
| 64 |
+
env = {**os.environ, "OLLAMA_HOST": f"{bind}:{PORT}", "OLLAMA_CONTEXT_LENGTH": NUM_CTX}
|
| 65 |
if keep_alive is not None:
|
| 66 |
env["OLLAMA_KEEP_ALIVE"] = keep_alive
|
| 67 |
subprocess.Popen(["ollama", "serve"], env=env)
|
|
@@ -1,27 +1,34 @@
|
|
| 1 |
"""tests/unit/test_assessment_logic.py — deterministic assessment fallbacks."""
|
| 2 |
|
|
|
|
| 3 |
from app.phases.assessment import _derive_case, _is_weak_reasoning
|
| 4 |
from app.state.session import SessionState
|
| 5 |
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
)
|
| 17 |
-
|
|
|
|
| 18 |
good = (
|
| 19 |
"Based on what you shared, you face a well-founded fear of persecution on "
|
| 20 |
"political grounds. Under the 1951 Convention this is a refugee claim, and "
|
| 21 |
-
"because you remain in your country you must seek protection abroad.
|
| 22 |
-
"Sweden, France and Italy all run active asylum procedures."
|
| 23 |
)
|
| 24 |
-
assert not _is_weak_reasoning(good)
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def _seed(types, danger):
|
|
|
|
| 1 |
"""tests/unit/test_assessment_logic.py — deterministic assessment fallbacks."""
|
| 2 |
|
| 3 |
+
from app.assessment_parse import AssessmentResult
|
| 4 |
from app.phases.assessment import _derive_case, _is_weak_reasoning
|
| 5 |
from app.state.session import SessionState
|
| 6 |
|
| 7 |
+
_EMPTY = AssessmentResult()
|
| 8 |
+
_FULL = AssessmentResult(case_type="refugee", grounds=["political opinion"],
|
| 9 |
+
risk="high", countries=["Sweden"])
|
| 10 |
|
| 11 |
+
|
| 12 |
+
def test_weak_reasoning_is_deterministic():
|
| 13 |
+
# Weak = no structured output, regardless of how the prose reads. A refusal or
|
| 14 |
+
# a chatbot greeting both produce no @@ASSESSMENT block, so both are weak —
|
| 15 |
+
# without pattern-matching the wording.
|
| 16 |
+
refusal = "Based on the information provided, I cannot determine a case type at this time, sorry."
|
| 17 |
+
greeting = ("Hello! I'm here to help you navigate your options. Could you tell me your "
|
| 18 |
+
"preferred language? If you have documents, please share them when ready.")
|
| 19 |
+
assert _is_weak_reasoning(refusal, _EMPTY)
|
| 20 |
+
assert _is_weak_reasoning(greeting, _EMPTY)
|
| 21 |
+
assert _is_weak_reasoning("too short", _EMPTY) # length floor
|
| 22 |
+
# A substantive analysis WITH structured output is not weak…
|
| 23 |
good = (
|
| 24 |
"Based on what you shared, you face a well-founded fear of persecution on "
|
| 25 |
"political grounds. Under the 1951 Convention this is a refugee claim, and "
|
| 26 |
+
"because you remain in your country you must seek protection abroad."
|
|
|
|
| 27 |
)
|
| 28 |
+
assert not _is_weak_reasoning(good, _FULL)
|
| 29 |
+
# …and the SAME good prose with no structured output is treated as weak
|
| 30 |
+
# (the model skipped the required block).
|
| 31 |
+
assert _is_weak_reasoning(good, _EMPTY)
|
| 32 |
|
| 33 |
|
| 34 |
def _seed(types, danger):
|