diff --git a/routes/chat.py b/routes/chat.py index c8252c9..df6939f 100644 --- a/routes/chat.py +++ b/routes/chat.py @@ -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"): @@ -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) diff --git a/tests/test_chat_escalation.py b/tests/test_chat_escalation.py index 384689c..ed72279 100644 --- a/tests/test_chat_escalation.py +++ b/tests/test_chat_escalation.py @@ -11,6 +11,8 @@ """ from __future__ import annotations +import pytest + import json import sys from pathlib import Path @@ -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