kenrockender Claude Opus 4.8 commited on
Commit
25ff65a
·
1 Parent(s): aefc853

Consistent LLM retry + DOCUMENTS token safety valve

Browse files

- Add rag.invoke_with_retry(): single retry path for every LLM call
(chat, recommender, coach/eval, helpers). Retries transient failures
(5xx/network/rate) up to llm_max_retries with backoff, fails fast on
permanent errors (bad key/model), re-raises when spent so each site's
fallback still runs. Routes all 6 prior raw .invoke() call sites through it.
- Wire max_docs_block_chars into _build_block via _within_budget(): BCA Life
docs always kept; competitor docs dropped largest-first only when over
budget. Default is generous so today's catalog is unchanged.
- Tests: budget disabled/under/over + home-never-dropped; retry
recover/exhaust/fail-fast. Full suite 25 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

app/config.py CHANGED
@@ -18,6 +18,18 @@ class Settings(BaseSettings):
18
  openrouter_coach_model: str = ""
19
  openrouter_eval_model: str = ""
20
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  def coach_model_name(self) -> str:
22
  return self.openrouter_coach_model.strip() or self.openrouter_chat_model
23
 
 
18
  openrouter_coach_model: str = ""
19
  openrouter_eval_model: str = ""
20
 
21
+ # Soft budget for the concatenated DOCUMENTS block that gets stuffed into
22
+ # every prompt. Our own (BCA Life) products are always kept in full for
23
+ # accuracy; competitor docs are dropped (largest first) once the block
24
+ # would exceed this, so token cost stays bounded as the catalog grows.
25
+ # Default is generous — the current catalog is well under it, so nothing
26
+ # changes today; this is a safety valve for later. ~4 chars ≈ 1 token, so
27
+ # 120k chars ≈ 30k tokens.
28
+ max_docs_block_chars: int = 120_000
29
+
30
+ # How many times to retry a transient LLM error before falling back.
31
+ llm_max_retries: int = 2
32
+
33
  def coach_model_name(self) -> str:
34
  return self.openrouter_coach_model.strip() or self.openrouter_chat_model
35
 
app/rag.py CHANGED
@@ -9,6 +9,7 @@ Design:
9
  lets DeepSeek's automatic prompt cache kick in (~10x cheaper, faster).
10
  """
11
  import re
 
12
  import logging
13
  import threading
14
  from typing import Dict, List, Optional, Tuple
@@ -126,6 +127,38 @@ def get_helper_llm() -> ChatOpenAI:
126
  return _helper_llm
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  # -----------------------------------------------------------------------------
130
  # In-memory document store
131
  # -----------------------------------------------------------------------------
@@ -148,6 +181,40 @@ def _invalidate_block() -> None:
148
  _DOCS_BLOCK = None
149
 
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  def _build_block() -> str:
152
  """Render the full DOCUMENTS block, grouped by insurer so the model can tell
153
  our products from competitors. Home insurer first, then others A→Z; stable
@@ -156,8 +223,10 @@ def _build_block() -> str:
156
  if not items:
157
  return "(belum ada dokumen produk yang diunggah)"
158
 
 
 
159
  def insurer_of(d):
160
- return d.get("insurer") or "Lainnya"
161
 
162
  groups: Dict[str, List[Dict[str, str]]] = {}
163
  for d in items:
@@ -377,7 +446,7 @@ def rewrite_standalone(question: str, history: List[Dict[str, str]]) -> str:
377
  f"{h['role'].upper()}: {h['content']}" for h in history[-4:]
378
  )
379
  try:
380
- out = get_helper_llm().invoke([
381
  SystemMessage(content=_REWRITE_SYSTEM),
382
  HumanMessage(content=(
383
  f"Conversation so far:\n{convo}\n\n"
@@ -490,7 +559,7 @@ def _sources_listing() -> List[Dict]:
490
 
491
 
492
  def _generate(system: str, user: str, *, llm=None) -> str:
493
- out = (llm or get_llm()).invoke([
494
  cached_system(system),
495
  HumanMessage(content=user),
496
  ])
 
9
  lets DeepSeek's automatic prompt cache kick in (~10x cheaper, faster).
10
  """
11
  import re
12
+ import time
13
  import logging
14
  import threading
15
  from typing import Dict, List, Optional, Tuple
 
127
  return _helper_llm
128
 
129
 
130
+ # Errors that won't fix themselves on a retry — bad key, malformed request. We
131
+ # fail these fast to the caller's fallback instead of burning the retry budget.
132
+ _PERMANENT_ERR_RX = re.compile(
133
+ r"\b(400|401|403|invalid.?api.?key|authenticat|unauthorized|no.?such.?model)\b",
134
+ re.IGNORECASE,
135
+ )
136
+
137
+
138
+ def invoke_with_retry(llm, messages):
139
+ """Invoke an LLM, retrying transient failures before giving up.
140
+
141
+ OpenRouter calls fail intermittently on network blips, provider 5xx, and
142
+ rate limits; a couple of retries with a short backoff turns most of those
143
+ into a success instead of a user-facing fallback. Every LLM call in the app
144
+ (chat, recommender, coach/eval, helpers) goes through here so the retry
145
+ behaviour is consistent. Permanent errors are re-raised immediately, and the
146
+ last error is re-raised once the budget (settings.llm_max_retries) is spent —
147
+ each call site keeps its own try/except fallback."""
148
+ attempts = max(1, settings.llm_max_retries + 1)
149
+ last_err: Optional[Exception] = None
150
+ for i in range(attempts):
151
+ try:
152
+ return llm.invoke(messages)
153
+ except Exception as e: # noqa: BLE001 — provider errors are opaque
154
+ last_err = e
155
+ if _PERMANENT_ERR_RX.search(str(e)) or i == attempts - 1:
156
+ break
157
+ log.warning("LLM invoke failed (attempt %d/%d): %s", i + 1, attempts, e)
158
+ time.sleep(0.5 * (i + 1))
159
+ raise last_err
160
+
161
+
162
  # -----------------------------------------------------------------------------
163
  # In-memory document store
164
  # -----------------------------------------------------------------------------
 
181
  _DOCS_BLOCK = None
182
 
183
 
184
+ def _insurer_of(d: Dict[str, str]) -> str:
185
+ return d.get("insurer") or "Lainnya"
186
+
187
+
188
+ def _within_budget(items: List[Dict[str, str]], budget: int) -> List[Dict[str, str]]:
189
+ """Enforce the soft DOCUMENTS char budget (settings.max_docs_block_chars).
190
+
191
+ Our own (BCA Life) products are always kept in full for accuracy; competitor
192
+ docs are dropped largest-first until the concatenated text fits. Order is
193
+ preserved for the caller. A non-positive budget disables the cap. The
194
+ default budget is generous, so a normal catalog keeps everything and the
195
+ prompt prefix stays byte-identical (cache-friendly)."""
196
+ if not budget or budget <= 0:
197
+ return items
198
+ total = sum(len(d.get("text") or "") for d in items)
199
+ if total <= budget:
200
+ return items
201
+ over = total
202
+ dropped: set = set()
203
+ competitors = [d for d in items if _insurer_of(d) != HOME_INSURER]
204
+ for d in sorted(competitors, key=lambda x: len(x.get("text") or ""), reverse=True):
205
+ if total <= budget:
206
+ break
207
+ total -= len(d.get("text") or "")
208
+ dropped.add(id(d))
209
+ if dropped:
210
+ log.warning(
211
+ "DOCUMENTS block over budget (%d > %d chars); dropped %d competitor "
212
+ "doc(s) largest-first to fit (BCA Life docs always kept)",
213
+ over, budget, len(dropped),
214
+ )
215
+ return [d for d in items if id(d) not in dropped]
216
+
217
+
218
  def _build_block() -> str:
219
  """Render the full DOCUMENTS block, grouped by insurer so the model can tell
220
  our products from competitors. Home insurer first, then others A→Z; stable
 
223
  if not items:
224
  return "(belum ada dokumen produk yang diunggah)"
225
 
226
+ items = _within_budget(items, settings.max_docs_block_chars)
227
+
228
  def insurer_of(d):
229
+ return _insurer_of(d)
230
 
231
  groups: Dict[str, List[Dict[str, str]]] = {}
232
  for d in items:
 
446
  f"{h['role'].upper()}: {h['content']}" for h in history[-4:]
447
  )
448
  try:
449
+ out = invoke_with_retry(get_helper_llm(), [
450
  SystemMessage(content=_REWRITE_SYSTEM),
451
  HumanMessage(content=(
452
  f"Conversation so far:\n{convo}\n\n"
 
559
 
560
 
561
  def _generate(system: str, user: str, *, llm=None) -> str:
562
+ out = invoke_with_retry(llm or get_llm(), [
563
  cached_system(system),
564
  HumanMessage(content=user),
565
  ])
app/recommender.py CHANGED
@@ -269,7 +269,7 @@ def recommend(profile: Dict) -> Dict:
269
  Berikan rekomendasi JSON sesuai format yang diminta."""
270
 
271
  try:
272
- out = rag.get_strict_llm().invoke([
273
  SystemMessage(content=system),
274
  HumanMessage(content=user_msg),
275
  ])
@@ -466,7 +466,7 @@ def _compare_llm(bca_text: str, comp_text: str, comp_insurer: str, mode: str) ->
466
  )
467
 
468
  try:
469
- out = rag.get_strict_llm().invoke([
470
  SystemMessage(content=COMPARE_SYSTEM),
471
  HumanMessage(content=user_msg),
472
  ])
 
269
  Berikan rekomendasi JSON sesuai format yang diminta."""
270
 
271
  try:
272
+ out = rag.invoke_with_retry(rag.get_strict_llm(), [
273
  SystemMessage(content=system),
274
  HumanMessage(content=user_msg),
275
  ])
 
466
  )
467
 
468
  try:
469
+ out = rag.invoke_with_retry(rag.get_strict_llm(), [
470
  SystemMessage(content=COMPARE_SYSTEM),
471
  HumanMessage(content=user_msg),
472
  ])
app/training.py CHANGED
@@ -176,7 +176,7 @@ def reply(session_id: str, fa_message: str) -> Dict:
176
  # filler/acknowledgement turns skip the second LLM call entirely.
177
  def _customer() -> str:
178
  try:
179
- out = rag.get_llm().invoke(msgs)
180
  return _clean_reply((out.content or "").strip())
181
  except Exception as e:
182
  log.exception("customer reply failed: %s", e)
@@ -356,7 +356,7 @@ def _coach_turn(
356
  f"FA menjawab: \"{fa_message}\"\n\n"
357
  "Nilai pesan FA itu. Output JSON."
358
  )
359
- out = rag.get_coach_llm().invoke([
360
  rag.cached_system(_COACH_SYSTEM),
361
  HumanMessage(content=user),
362
  ])
@@ -477,7 +477,7 @@ Kasih evaluasi JSON sesuai format yang diminta."""
477
  last_error = ""
478
  for attempt in range(3):
479
  try:
480
- out = rag.get_eval_llm().invoke([
481
  rag.cached_system(EVAL_SYSTEM),
482
  HumanMessage(content=user_block),
483
  ])
 
176
  # filler/acknowledgement turns skip the second LLM call entirely.
177
  def _customer() -> str:
178
  try:
179
+ out = rag.invoke_with_retry(rag.get_llm(), msgs)
180
  return _clean_reply((out.content or "").strip())
181
  except Exception as e:
182
  log.exception("customer reply failed: %s", e)
 
356
  f"FA menjawab: \"{fa_message}\"\n\n"
357
  "Nilai pesan FA itu. Output JSON."
358
  )
359
+ out = rag.invoke_with_retry(rag.get_coach_llm(), [
360
  rag.cached_system(_COACH_SYSTEM),
361
  HumanMessage(content=user),
362
  ])
 
477
  last_error = ""
478
  for attempt in range(3):
479
  try:
480
+ out = rag.invoke_with_retry(rag.get_eval_llm(), [
481
  rag.cached_system(EVAL_SYSTEM),
482
  HumanMessage(content=user_block),
483
  ])
tests/test_rag_helpers.py CHANGED
@@ -32,3 +32,81 @@ def test_is_no_answer():
32
  assert rag._is_no_answer("NO_ANSWER") is True
33
  assert rag._is_no_answer("") is True
34
  assert rag._is_no_answer("Produk ini memberikan manfaat proteksi jiwa.") is False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  assert rag._is_no_answer("NO_ANSWER") is True
33
  assert rag._is_no_answer("") is True
34
  assert rag._is_no_answer("Produk ini memberikan manfaat proteksi jiwa.") is False
35
+
36
+
37
+ # --- DOCUMENTS block soft budget (safety valve) ------------------------------
38
+
39
+ def _doc(insurer, n):
40
+ return {"name": f"{insurer}-{n}", "type": "txt", "insurer": insurer, "text": "x" * n}
41
+
42
+
43
+ def test_within_budget_disabled_keeps_all():
44
+ items = [_doc("BCA Life", 100), _doc("Prudential", 100)]
45
+ assert rag._within_budget(items, 0) == items
46
+
47
+
48
+ def test_within_budget_under_budget_keeps_all():
49
+ items = [_doc("BCA Life", 100), _doc("Prudential", 100)]
50
+ assert rag._within_budget(items, 10_000) == items
51
+
52
+
53
+ def test_within_budget_drops_largest_competitor_first():
54
+ home = _doc("BCA Life", 500)
55
+ small_comp = _doc("AIA", 100)
56
+ big_comp = _doc("Prudential", 900)
57
+ kept = rag._within_budget([home, small_comp, big_comp], 700)
58
+ # Home is never dropped; the largest competitor goes first.
59
+ assert home in kept
60
+ assert big_comp not in kept
61
+ assert small_comp in kept
62
+
63
+
64
+ def test_within_budget_never_drops_home_even_if_over():
65
+ home = _doc("BCA Life", 5_000)
66
+ comp = _doc("Prudential", 100)
67
+ kept = rag._within_budget([home, comp], 1_000)
68
+ assert home in kept # home kept even though it alone exceeds the budget
69
+ assert comp not in kept
70
+
71
+
72
+ # --- LLM retry helper --------------------------------------------------------
73
+
74
+ class _FlakyLLM:
75
+ """Fails `fail_times` invokes with `exc`, then returns `ok`."""
76
+ def __init__(self, fail_times, exc, ok="ok"):
77
+ self.fail_times = fail_times
78
+ self.exc = exc
79
+ self.ok = ok
80
+ self.calls = 0
81
+
82
+ def invoke(self, messages):
83
+ self.calls += 1
84
+ if self.calls <= self.fail_times:
85
+ raise self.exc
86
+ return self.ok
87
+
88
+
89
+ def test_invoke_with_retry_recovers_from_transient(monkeypatch):
90
+ monkeypatch.setattr(rag.time, "sleep", lambda *_: None)
91
+ monkeypatch.setattr(rag.settings, "llm_max_retries", 2)
92
+ llm = _FlakyLLM(fail_times=1, exc=RuntimeError("503 upstream"))
93
+ assert rag.invoke_with_retry(llm, []) == "ok"
94
+ assert llm.calls == 2
95
+
96
+
97
+ def test_invoke_with_retry_exhausts_budget(monkeypatch):
98
+ monkeypatch.setattr(rag.time, "sleep", lambda *_: None)
99
+ monkeypatch.setattr(rag.settings, "llm_max_retries", 2)
100
+ llm = _FlakyLLM(fail_times=99, exc=RuntimeError("network"))
101
+ with pytest.raises(RuntimeError):
102
+ rag.invoke_with_retry(llm, [])
103
+ assert llm.calls == 3 # 1 initial + 2 retries
104
+
105
+
106
+ def test_invoke_with_retry_fails_fast_on_permanent(monkeypatch):
107
+ monkeypatch.setattr(rag.time, "sleep", lambda *_: None)
108
+ monkeypatch.setattr(rag.settings, "llm_max_retries", 2)
109
+ llm = _FlakyLLM(fail_times=99, exc=RuntimeError("401 invalid api key"))
110
+ with pytest.raises(RuntimeError):
111
+ rag.invoke_with_retry(llm, [])
112
+ assert llm.calls == 1 # no retry on a permanent error