MukulRay commited on
Commit
916f1e7
·
1 Parent(s): bc03d8b

Session-state audit fix: thread session_id end-to-end, advance substantive-only, use monotonic seq counter

Browse files

Four interlocking bugs that together made the listening layer fragile
on the live demo:

1. demo/app.py hardcoded session_id='demo' in FastDemoPipeline.run /
run_streaming. The fresh uuid generated by the 'New conversation'
button (and by every page-load via gr.State) was NEVER passed to
the core. Every conversation across users / refreshes / resets
shared the same 'demo' state entry in EmpathRAGCore, causing
state accumulation forever. Now session_id flows from session_state
through respond() into both run() and run_streaming().

2. reset_session_handler created a new uuid but did not clear the
core's lingering state for the OLD session_id. Added a call to
pipeline.reset_session(session_id=prev_sid) that the button now
invokes via wiring through the session_state input.

3. tier_history is a rolling window of size 3, so the
tier_history-based turn_index saturates at 4 forever. That made
the consent-flow recency check (current_turn - offer_turn in 1..2)
silently fail past the fourth turn. Added session_seq — a
monotonic per-session counter that does not cap — and use it for
open_offer.offer_turn, acknowledged_turn, delivered_turn, and the
_open_offer_recent diff. turn_index keeps its semantics for the
safety tracker.

4. Greetings / hedges / incomplete-message clarifies consumed
listening-layer stage slots because decide_stage was driven by
raw turn_index. A user who opened with 'Hi' then said something
substantive landed at PERMISSION, not LISTEN — invisible to the
listening layer demo. Added session_substantive_count which only
increments on LISTEN / PERMISSION / OFFER stages, and decide_stage
now reads effective_turn_index from that counter. CLARIFY turns
no longer consume the slot.

Verified with six end-to-end scenarios: greeting-then-loop, clean
5-turn, many-trailing-affirms (no infinite loop), parallel users (no
leak), crisis fresh, and hedge-then-loop.

Files changed (2) hide show
  1. demo/app.py +46 -13
  2. src/pipeline/core.py +48 -8
demo/app.py CHANGED
@@ -1218,17 +1218,27 @@ class FastDemoPipeline:
1218
  self._crisis_locked = False
1219
  self._last_escalation_reason = ""
1220
 
1221
- def run(self, user_message: str, audience_mode: str = "student") -> dict:
 
 
 
 
 
1222
  core_result = self.core.run_turn(
1223
  message=user_message,
1224
- session_id="demo",
1225
  audience_mode=audience_mode,
1226
  resource_profile="umd",
1227
  backend_mode="hybrid_ml",
1228
  ).to_dict()
1229
  return self._enrich_result(core_result)
1230
 
1231
- def run_streaming(self, user_message: str, audience_mode: str = "student"):
 
 
 
 
 
1232
  """Generator wrapping ``EmpathRAGCore.run_turn_streaming``.
1233
 
1234
  Yields ``("token", text)`` for each streamed chunk and ``("done",
@@ -1236,7 +1246,7 @@ class FastDemoPipeline:
1236
  """
1237
  for event in self.core.run_turn_streaming(
1238
  message=user_message,
1239
- session_id="demo",
1240
  audience_mode=audience_mode,
1241
  resource_profile="umd",
1242
  backend_mode="hybrid_ml",
@@ -1380,12 +1390,12 @@ class FastDemoPipeline:
1380
  def tracker_trajectory(self) -> str:
1381
  return "stable"
1382
 
1383
- def reset_session(self) -> None:
1384
  self._turn = 0
1385
  self._tier_history = []
1386
  self._crisis_locked = False
1387
  self._last_escalation_reason = ""
1388
- self.core.reset_session("demo")
1389
 
1390
  def _result(
1391
  self,
@@ -2558,13 +2568,15 @@ def respond(message, chat_history, session_state, audience_mode, rephrase_mode="
2558
  def fast_check(text, threshold=0.5, skip_ig=False):
2559
  return original_check(text, threshold=threshold, skip_ig=True)
2560
  active_pipeline.guardrail.check = fast_check
2561
- result = active_pipeline.run(message)
2562
  active_pipeline.guardrail.check = original_check
2563
  session_state["tracker_history"] = active_pipeline.tracker.history()
2564
  session_state["conv_history"] = list(active_pipeline.conv_history)
2565
  elif use_real_streaming:
2566
  result = None
2567
- for ev in active_pipeline.run_streaming(message, audience_mode=audience_mode or "student"):
 
 
2568
  if ev[0] == "token":
2569
  chat_history[-1] = (message, ev[1])
2570
  yield (
@@ -2579,7 +2591,9 @@ def respond(message, chat_history, session_state, audience_mode, rephrase_mode="
2579
  session_state["tracker_history"] = session_state.get("tracker_history", []) + [result["emotion"]]
2580
  session_state["conv_history"] = session_state.get("conv_history", [])
2581
  else:
2582
- result = active_pipeline.run(message, audience_mode=audience_mode or "student")
 
 
2583
  session_state["tracker_history"] = session_state.get("tracker_history", []) + [result["emotion"]]
2584
  session_state["conv_history"] = session_state.get("conv_history", [])
2585
 
@@ -2739,7 +2753,26 @@ def export_support_plan_pdf(session_state):
2739
  return export_support_plan_md(session_state)
2740
 
2741
 
2742
- def reset_session_handler():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2743
  session_state = new_session_state()
2744
  return (
2745
  [],
@@ -3090,11 +3123,11 @@ with gr.Blocks(theme=theme, title="EmpathRAG Studio", css=APP_CSS, js=_CHATBOT_A
3090
  outputs=submit_outputs,
3091
  ).then(_clear_input, outputs=msg_box)
3092
 
3093
- def reset_with_chrome():
3094
- base = reset_session_handler()
3095
  return base + (gr.update(visible=True),)
3096
 
3097
- reset_btn.click(reset_with_chrome, outputs=submit_outputs)
3098
 
3099
  export_pdf_btn.click(export_support_plan_pdf, inputs=[session_state], outputs=[support_plan_file])
3100
  export_md_btn.click(export_support_plan_md, inputs=[session_state], outputs=[support_plan_file])
 
1218
  self._crisis_locked = False
1219
  self._last_escalation_reason = ""
1220
 
1221
+ def run(
1222
+ self,
1223
+ user_message: str,
1224
+ audience_mode: str = "student",
1225
+ session_id: str = "demo",
1226
+ ) -> dict:
1227
  core_result = self.core.run_turn(
1228
  message=user_message,
1229
+ session_id=session_id,
1230
  audience_mode=audience_mode,
1231
  resource_profile="umd",
1232
  backend_mode="hybrid_ml",
1233
  ).to_dict()
1234
  return self._enrich_result(core_result)
1235
 
1236
+ def run_streaming(
1237
+ self,
1238
+ user_message: str,
1239
+ audience_mode: str = "student",
1240
+ session_id: str = "demo",
1241
+ ):
1242
  """Generator wrapping ``EmpathRAGCore.run_turn_streaming``.
1243
 
1244
  Yields ``("token", text)`` for each streamed chunk and ``("done",
 
1246
  """
1247
  for event in self.core.run_turn_streaming(
1248
  message=user_message,
1249
+ session_id=session_id,
1250
  audience_mode=audience_mode,
1251
  resource_profile="umd",
1252
  backend_mode="hybrid_ml",
 
1390
  def tracker_trajectory(self) -> str:
1391
  return "stable"
1392
 
1393
+ def reset_session(self, session_id: str = "demo") -> None:
1394
  self._turn = 0
1395
  self._tier_history = []
1396
  self._crisis_locked = False
1397
  self._last_escalation_reason = ""
1398
+ self.core.reset_session(session_id)
1399
 
1400
  def _result(
1401
  self,
 
2568
  def fast_check(text, threshold=0.5, skip_ig=False):
2569
  return original_check(text, threshold=threshold, skip_ig=True)
2570
  active_pipeline.guardrail.check = fast_check
2571
+ result = active_pipeline.run(message, session_id=session_id)
2572
  active_pipeline.guardrail.check = original_check
2573
  session_state["tracker_history"] = active_pipeline.tracker.history()
2574
  session_state["conv_history"] = list(active_pipeline.conv_history)
2575
  elif use_real_streaming:
2576
  result = None
2577
+ for ev in active_pipeline.run_streaming(
2578
+ message, audience_mode=audience_mode or "student", session_id=session_id
2579
+ ):
2580
  if ev[0] == "token":
2581
  chat_history[-1] = (message, ev[1])
2582
  yield (
 
2591
  session_state["tracker_history"] = session_state.get("tracker_history", []) + [result["emotion"]]
2592
  session_state["conv_history"] = session_state.get("conv_history", [])
2593
  else:
2594
+ result = active_pipeline.run(
2595
+ message, audience_mode=audience_mode or "student", session_id=session_id
2596
+ )
2597
  session_state["tracker_history"] = session_state.get("tracker_history", []) + [result["emotion"]]
2598
  session_state["conv_history"] = session_state.get("conv_history", [])
2599
 
 
2753
  return export_support_plan_md(session_state)
2754
 
2755
 
2756
+ def reset_session_handler(prev_session_state=None):
2757
+ """Reset to a brand-new session. Crucially, also clears any lingering
2758
+ EmpathRAGCore state keyed to the previous session_id (tier_history,
2759
+ open-offer slot, last-stage marker, intl flags, message history).
2760
+ Without this, clicking "New conversation" only swapped the UI label
2761
+ while the core kept growing state under the old key — which is why a
2762
+ "fresh" run on the demo could still see prior turns in context.
2763
+ """
2764
+ prev_sid = (prev_session_state or {}).get("session_id")
2765
+ with pipeline_lock:
2766
+ try:
2767
+ pipeline = get_pipeline()
2768
+ if hasattr(pipeline, "reset_session") and prev_sid:
2769
+ pipeline.reset_session(session_id=prev_sid)
2770
+ elif hasattr(pipeline, "reset_session"):
2771
+ pipeline.reset_session()
2772
+ except Exception:
2773
+ # Reset must never block UI; failed resets degrade silently
2774
+ # because the new session_id alone gives a clean state entry.
2775
+ pass
2776
  session_state = new_session_state()
2777
  return (
2778
  [],
 
3123
  outputs=submit_outputs,
3124
  ).then(_clear_input, outputs=msg_box)
3125
 
3126
+ def reset_with_chrome(prev_session_state):
3127
+ base = reset_session_handler(prev_session_state)
3128
  return base + (gr.update(visible=True),)
3129
 
3130
+ reset_btn.click(reset_with_chrome, inputs=[session_state], outputs=submit_outputs)
3131
 
3132
  export_pdf_btn.click(export_support_plan_pdf, inputs=[session_state], outputs=[support_plan_file])
3133
  export_md_btn.click(export_support_plan_md, inputs=[session_state], outputs=[support_plan_file])
src/pipeline/core.py CHANGED
@@ -18,7 +18,9 @@ from .output_guard import validate_output
18
  from .response_planner import (
19
  CLARIFY,
20
  INTERNATIONAL_SOURCE_HINT,
 
21
  OFFER,
 
22
  build_response_plan,
23
  classify_intl_topic,
24
  decide_stage,
@@ -180,6 +182,19 @@ class EmpathRAGCore:
180
  # to widen things out), while an affirm after OFFER should go
181
  # into the consent flow (initial -> acknowledged -> delivered).
182
  self.session_last_stage: dict[str, str] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  # Rolling per-session message log so the rephraser has continuity.
184
  # Only the last few user-assistant pairs are passed to the LLM; full
185
  # history is kept here for diagnostics. Each entry: {"role", "content"}.
@@ -194,6 +209,8 @@ class EmpathRAGCore:
194
  self.session_last_specific_route.pop(session_id, None)
195
  self.session_open_offer.pop(session_id, None)
196
  self.session_last_stage.pop(session_id, None)
 
 
197
  self.session_message_history.pop(session_id, None)
198
  else:
199
  self.tier_history.clear()
@@ -203,6 +220,8 @@ class EmpathRAGCore:
203
  self.session_last_specific_route.clear()
204
  self.session_open_offer.clear()
205
  self.session_last_stage.clear()
 
 
206
  self.session_message_history.clear()
207
 
208
  # Input length cap: defends against (1) accidental wall-of-text pastes
@@ -326,6 +345,12 @@ class EmpathRAGCore:
326
  ) -> _TurnPlan:
327
  if turn_index is None:
328
  turn_index = len(self.tier_history.get(session_id, [])) + 1
 
 
 
 
 
 
329
  t_total = time.perf_counter()
330
  latency: dict[str, float] = {}
331
 
@@ -450,7 +475,13 @@ class EmpathRAGCore:
450
  audience_mode,
451
  international_concern_override=intl_active,
452
  )
453
- stage = decide_stage(message, route_label, safety_tier.value, turn_index)
 
 
 
 
 
 
454
  # Only classify F-1 sub-topic when the framing is actively in play.
455
  # After 2 silent turns, decayed; don't pin the planner to OPT/RCL/etc.
456
  intl_topic = classify_intl_topic(message) if intl_active else ""
@@ -492,7 +523,7 @@ class EmpathRAGCore:
492
  minimal_kind == "affirm"
493
  and self.session_last_stage.get(session_id) == "permission"
494
  and not _open_offer_recent(
495
- self.session_open_offer.get(session_id), turn_index
496
  )
497
  ):
498
  # The student said yes to widening out after a PERMISSION
@@ -505,7 +536,7 @@ class EmpathRAGCore:
505
  recommended_action = response_plan.recommended_action
506
  stage = OFFER
507
  elif minimal_kind == "affirm" and _open_offer_recent(
508
- self.session_open_offer.get(session_id), turn_index
509
  ):
510
  # The student just said yes/ok to an OFFER we made on a
511
  # recent turn. Re-rendering the same template at this point
@@ -522,7 +553,7 @@ class EmpathRAGCore:
522
  offer_state.get("question", ""),
523
  )
524
  offer_state["stage"] = "acknowledged"
525
- offer_state["acknowledged_turn"] = turn_index
526
  self.session_open_offer[session_id] = offer_state
527
  stage = OFFER
528
  else:
@@ -530,7 +561,7 @@ class EmpathRAGCore:
530
  offer_state.get("route", "")
531
  )
532
  offer_state["stage"] = "delivered"
533
- offer_state["delivered_turn"] = turn_index
534
  self.session_open_offer[session_id] = offer_state
535
  stage = OFFER
536
  recommended_action = response_plan.recommended_action
@@ -560,8 +591,8 @@ class EmpathRAGCore:
560
  # would reset the consent progression.
561
  existing_offer = self.session_open_offer.get(session_id) or {}
562
  advanced_this_turn = (
563
- existing_offer.get("acknowledged_turn") == turn_index
564
- or existing_offer.get("delivered_turn") == turn_index
565
  )
566
  if (
567
  stage == OFFER
@@ -572,7 +603,7 @@ class EmpathRAGCore:
572
  self.session_open_offer[session_id] = {
573
  "route": route_label,
574
  "question": response_plan.follow_up_question.strip(),
575
- "offer_turn": turn_index,
576
  "stage": "initial",
577
  }
578
  elif (
@@ -590,6 +621,15 @@ class EmpathRAGCore:
590
  # PERMISSION, consent-flow after OFFER).
591
  if not should_intercept:
592
  self.session_last_stage[session_id] = stage
 
 
 
 
 
 
 
 
 
593
 
594
  return _TurnPlan(
595
  message=message,
 
18
  from .response_planner import (
19
  CLARIFY,
20
  INTERNATIONAL_SOURCE_HINT,
21
+ LISTEN,
22
  OFFER,
23
+ PERMISSION,
24
  build_response_plan,
25
  classify_intl_topic,
26
  decide_stage,
 
182
  # to widen things out), while an affirm after OFFER should go
183
  # into the consent flow (initial -> acknowledged -> delivered).
184
  self.session_last_stage: dict[str, str] = {}
185
+ # Count of "substantive" turns (LISTEN / PERMISSION / OFFER)
186
+ # rendered for this session. Non-substantive turns (greeting,
187
+ # goodbye, meta, minimal-affirm clarify, incomplete-message
188
+ # clarify) do not increment this. Used so a user who opens with
189
+ # "Hi" then says something real lands at LISTEN, not PERMISSION
190
+ # — the greeting shouldn't consume the listening-layer slot.
191
+ self.session_substantive_count: dict[str, int] = {}
192
+ # Monotonic per-session turn counter that does NOT cap. Used by
193
+ # the consent flow to measure "how many turns ago was the open
194
+ # offer rendered" — the tier_history-based turn_index saturates
195
+ # at 4 because tier_history is a rolling deque of size 3, which
196
+ # makes diff-based recency checks fail past turn 4.
197
+ self.session_seq: dict[str, int] = {}
198
  # Rolling per-session message log so the rephraser has continuity.
199
  # Only the last few user-assistant pairs are passed to the LLM; full
200
  # history is kept here for diagnostics. Each entry: {"role", "content"}.
 
209
  self.session_last_specific_route.pop(session_id, None)
210
  self.session_open_offer.pop(session_id, None)
211
  self.session_last_stage.pop(session_id, None)
212
+ self.session_substantive_count.pop(session_id, None)
213
+ self.session_seq.pop(session_id, None)
214
  self.session_message_history.pop(session_id, None)
215
  else:
216
  self.tier_history.clear()
 
220
  self.session_last_specific_route.clear()
221
  self.session_open_offer.clear()
222
  self.session_last_stage.clear()
223
+ self.session_substantive_count.clear()
224
+ self.session_seq.clear()
225
  self.session_message_history.clear()
226
 
227
  # Input length cap: defends against (1) accidental wall-of-text pastes
 
345
  ) -> _TurnPlan:
346
  if turn_index is None:
347
  turn_index = len(self.tier_history.get(session_id, [])) + 1
348
+ # Monotonic sequence — used wherever the diff-from-prior-turn
349
+ # comparison matters (consent recency, last-stage marker). The
350
+ # tier_history-based turn_index above caps because the safety
351
+ # tracker only keeps the last 3 entries.
352
+ self.session_seq[session_id] = self.session_seq.get(session_id, 0) + 1
353
+ seq = self.session_seq[session_id]
354
  t_total = time.perf_counter()
355
  latency: dict[str, float] = {}
356
 
 
475
  audience_mode,
476
  international_concern_override=intl_active,
477
  )
478
+ # Stage progression should count *substantive* turns only.
479
+ # Greetings / goodbyes / meta / minimal-affirms / incomplete
480
+ # fragments do not consume the listening layer slot — a
481
+ # student who opens with "Hi" then says something real should
482
+ # land at LISTEN, not PERMISSION.
483
+ effective_turn_index = self.session_substantive_count.get(session_id, 0) + 1
484
+ stage = decide_stage(message, route_label, safety_tier.value, effective_turn_index)
485
  # Only classify F-1 sub-topic when the framing is actively in play.
486
  # After 2 silent turns, decayed; don't pin the planner to OPT/RCL/etc.
487
  intl_topic = classify_intl_topic(message) if intl_active else ""
 
523
  minimal_kind == "affirm"
524
  and self.session_last_stage.get(session_id) == "permission"
525
  and not _open_offer_recent(
526
+ self.session_open_offer.get(session_id), seq
527
  )
528
  ):
529
  # The student said yes to widening out after a PERMISSION
 
536
  recommended_action = response_plan.recommended_action
537
  stage = OFFER
538
  elif minimal_kind == "affirm" and _open_offer_recent(
539
+ self.session_open_offer.get(session_id), seq
540
  ):
541
  # The student just said yes/ok to an OFFER we made on a
542
  # recent turn. Re-rendering the same template at this point
 
553
  offer_state.get("question", ""),
554
  )
555
  offer_state["stage"] = "acknowledged"
556
+ offer_state["acknowledged_turn"] = seq
557
  self.session_open_offer[session_id] = offer_state
558
  stage = OFFER
559
  else:
 
561
  offer_state.get("route", "")
562
  )
563
  offer_state["stage"] = "delivered"
564
+ offer_state["delivered_turn"] = seq
565
  self.session_open_offer[session_id] = offer_state
566
  stage = OFFER
567
  recommended_action = response_plan.recommended_action
 
591
  # would reset the consent progression.
592
  existing_offer = self.session_open_offer.get(session_id) or {}
593
  advanced_this_turn = (
594
+ existing_offer.get("acknowledged_turn") == seq
595
+ or existing_offer.get("delivered_turn") == seq
596
  )
597
  if (
598
  stage == OFFER
 
603
  self.session_open_offer[session_id] = {
604
  "route": route_label,
605
  "question": response_plan.follow_up_question.strip(),
606
+ "offer_turn": seq,
607
  "stage": "initial",
608
  }
609
  elif (
 
621
  # PERMISSION, consent-flow after OFFER).
622
  if not should_intercept:
623
  self.session_last_stage[session_id] = stage
624
+ # Substantive stages (LISTEN / PERMISSION / OFFER) move
625
+ # the listening-layer counter forward. CLARIFY turns
626
+ # (greeting, goodbye, meta, minimal-affirm fallback,
627
+ # incomplete-message handler) do not — they're outside
628
+ # the listening loop's stage progression.
629
+ if stage in (LISTEN, PERMISSION, OFFER):
630
+ self.session_substantive_count[session_id] = (
631
+ self.session_substantive_count.get(session_id, 0) + 1
632
+ )
633
 
634
  return _TurnPlan(
635
  message=message,