Session-state audit fix: thread session_id end-to-end, advance substantive-only, use monotonic seq counter
Browse filesFour 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.
- demo/app.py +46 -13
- src/pipeline/core.py +48 -8
|
@@ -1218,17 +1218,27 @@ class FastDemoPipeline:
|
|
| 1218 |
self._crisis_locked = False
|
| 1219 |
self._last_escalation_reason = ""
|
| 1220 |
|
| 1221 |
-
def run(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1222 |
core_result = self.core.run_turn(
|
| 1223 |
message=user_message,
|
| 1224 |
-
session_id=
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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(
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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])
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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),
|
| 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),
|
| 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"] =
|
| 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"] =
|
| 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") ==
|
| 564 |
-
or existing_offer.get("delivered_turn") ==
|
| 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":
|
| 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,
|