Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,12 @@ def _maybe_escalate_suggestion(user_text: str, session_id: str):
(>= 60 chars + an action verb) pays for classification. Returns the
suggestion dict for the UI chip, or None."""
try:
if len(user_text or "") < 60 or not _ESCALATE_HINT_RE.search(user_text):
# 2026-07 fix: the floor was 60, which silently dropped legitimate complex
# asks like "plan and build me a 5-page competitor report with charts"
# (56 chars) — the offer never fired, the prompt just degenerated in chat.
# 24 still filters trivial single-verb messages; the action-verb regex +
# the Qwen classifier are the real gate.
if len(user_text or "") < 24 or not _ESCALATE_HINT_RE.search(user_text):
return None
verdict = _should_escalate_to_project(user_text, session_id)
if not verdict.get("escalate"):
Expand Down Expand Up @@ -1087,9 +1092,9 @@ def _resolve_skill_tag(raw_tag):
)
else:
yield _frame(
"⚠️ The model returned an empty reply — it may be "
"*The model returned an empty reply — it may be "
"busy, restarting, or out of context. Please try "
"again in a moment."
"again in a moment.*"
)
# Step 10 Q11 (2026-07): post-reply Project suggestion.
_sugg = _maybe_escalate_suggestion(last_user_text, _session_id)
Expand Down
34 changes: 34 additions & 0 deletions tests/test_chat_escalation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"""
from __future__ import annotations

import pytest

import json
import sys
from pathlib import Path
Expand Down Expand Up @@ -131,3 +133,35 @@ def test_kill_switch_disables_all_escalation(monkeypatch):

decision = cd._should_escalate_to_project(user_text="x", session_id="s5")
assert decision["escalate"] is False


# ── 2026-07: the length floor dropped legit 56-char asks; lowered 60 -> 24 ─────

def test_maybe_escalate_fires_for_short_but_complex_ask(monkeypatch):
"""'plan and build me a 5-page competitor report with charts' is 56 chars —
it used to be silently dropped by the len<60 gate and just degenerated in
chat. It must now reach the classifier and produce an offer."""
import routes.chat as rc
monkeypatch.setattr(rc, "_should_escalate_to_project",
lambda text, sid: {"escalate": True, "estimated_checkpoints": 5,
"reason": "multi-step report", "is_project": True})
out = rc._maybe_escalate_suggestion(
"plan and build me a 5-page competitor report with charts", "sess-1")
assert out is not None, "offer must fire for a complex 56-char ask"
assert out["estimated_checkpoints"] == 5


def test_maybe_escalate_skips_trivial_messages(monkeypatch):
import routes.chat as rc
monkeypatch.setattr(rc, "_should_escalate_to_project",
lambda text, sid: pytest.fail("classifier must not run on trivial text"))
assert rc._maybe_escalate_suggestion("build", "s") is None # too short
assert rc._maybe_escalate_suggestion("what's the weather today?", "s") is None # no action verb


def test_maybe_escalate_respects_classifier_no(monkeypatch):
import routes.chat as rc
monkeypatch.setattr(rc, "_should_escalate_to_project",
lambda text, sid: {"escalate": False, "estimated_checkpoints": 1})
assert rc._maybe_escalate_suggestion(
"research the best pizza place near me right now", "s") is None