helmo Codex commited on
Commit
cd3c555
·
1 Parent(s): e88a54f

[fix] Give Ollama a real context window (root cause); deterministic weak-check

Browse files

Root 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 CHANGED
@@ -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
app/phases/assessment.py CHANGED
@@ -161,29 +161,18 @@ _GROUND_MAP = {
161
  }
162
 
163
 
164
- # Phrases that mark a non-answer from the model — either a refusal / "need more
165
- # info", or a conversational greeting that ignores the facts already collected and
166
- # asks the person for input. Either way, replace it with the deterministic,
167
- # interview-grounded analysis (the facts are all in hand already).
168
- _WEAK_MARKERS = (
169
- # refusals / "need more information"
170
- "cannot determine", "could not determine", "can't determine", "unable to determine",
171
- "need more", "more information", "additional details", "additional information",
172
- "not enough information", "insufficient information", "unable to assess",
173
- "cannot assess", "i cannot provide", "no specific case",
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 any(m in t for m in _WEAK_MARKERS)
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)
deploy/modal_app.py CHANGED
@@ -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)
tests/unit/test_assessment_logic.py CHANGED
@@ -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
- def test_weak_reasoning_detected():
8
- # refusals / non-answers
9
- assert _is_weak_reasoning("Based on the information provided, I cannot determine a case type.")
10
- assert _is_weak_reasoning("Additional details about your situation are needed to proceed.")
11
- assert _is_weak_reasoning("too short")
12
- # a conversational greeting that ignores the collected facts
13
- assert _is_weak_reasoning(
14
- "Hello! I'm here to help you navigate your options. Could you tell me your "
15
- "preferred language? If you have documents, please share them when you're ready."
16
- )
17
- # a substantive analysis is NOT weak
 
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):