diff --git a/backend/app/api/chat_routes.py b/backend/app/api/chat_routes.py index 162af13..b415605 100644 --- a/backend/app/api/chat_routes.py +++ b/backend/app/api/chat_routes.py @@ -251,13 +251,30 @@ def _public_debrief(sim: object, messages: list[dict], outcome: str, score: int) def _public_customer_opener(locale: object) -> str: + # Fallback only when a (legacy) persona has no generated `opener`. + # Must read like a real inquiring customer, not a system notice. return ( - "Hi, a customer has started the conversation." + "Hi, I found your page and I'm interested — can I ask a few questions about the product?" if locale == "en" - else "ลูกค้าเริ่มต้นบทสนทนาแล้ว ลองทักและค้นหาความต้องการดูครับ" + else "สวัสดีครับ เห็นสินค้าของคุณแล้วสนใจเลย ขอสอบถามรายละเอียดหน่อยได้ไหมครับ" ) +def _customer_opener_text(persona: dict, locale: object) -> str: + """First customer line = the persona's realistic greeting (public by design). + + The `opener` is what the customer says FIRST for customer-initiated channels + (social / a lead who messages in). It is generated to be a clean, polite, + in-character greeting (see OPENER RULE) — no latent pains/budget/personality — + so it is safe to surface. Fall back to a generic greeting only if the persona + has no opener (e.g. legacy records). + """ + opener = persona.get("opener") + if isinstance(opener, str) and opener.strip(): + return opener.strip()[:2000] + return _public_customer_opener(locale) + + _SAFE_SYSTEM_MESSAGES = frozenset({ "⏳ ผ่านไป 2-3 สัปดาห์ ... ลูกค้าที่เคยสอบถามไปเงียบไประยะหนึ่ง ตอนนี้กลับมาติดต่ออีกครั้ง (พร้อมตัดสินใจมากขึ้น)", "⏳ 2-3 weeks later ... the customer who asked earlier went quiet; now they re-contact, more ready to decide.", @@ -266,16 +283,10 @@ _SAFE_SYSTEM_MESSAGES = frozenset({ }) -def _safe_session_messages( - value: object, - *, - redact_initial_customer: bool = False, - locale: object = "th", -) -> list[dict[str, str]]: +def _safe_session_messages(value: object) -> list[dict[str, str]]: if not isinstance(value, list): return [] messages = [] - seller_seen = False for item in value[-100:]: if not isinstance(item, dict): continue @@ -286,10 +297,6 @@ def _safe_session_messages( if role == "system" and text not in _SAFE_SYSTEM_MESSAGES: continue if role in {"system", "seller", "customer"}: - if role == "customer" and redact_initial_customer and not seller_seen: - text = _public_customer_opener(locale) - if role == "seller": - seller_seen = True messages.append({"role": role, "text": text[:4000]}) return messages @@ -324,11 +331,7 @@ def serialize_session(session: dict) -> dict: "mode": mode, "status": session.get("status") if session.get("status") in ("active", "finished") else "active", "outcome": outcome, - "messages": _safe_session_messages( - session.get("messages"), - redact_initial_customer=True, - locale=safe_meta["locale"], - ), + "messages": _safe_session_messages(session.get("messages")), "debrief": debrief, } @@ -620,7 +623,7 @@ def start_session(gid: str, pid: str): if scenario_meta.get("preamble"): seeded.append({"role": "system", "text": scenario_meta["preamble"]}) if init_mode == "customer": - seeded.append({"role": "customer", "text": _public_customer_opener(locale)}) + seeded.append({"role": "customer", "text": _customer_opener_text(persona, locale)}) initial_internal = {"turns": 0, "score": 50, "signals": []} # RESUME/create is one atomic operation over the complete tenant scope. A @@ -640,7 +643,7 @@ def start_session(gid: str, pid: str): if scenario_meta.get("preamble"): seeded.append({"role": "system", "text": scenario_meta["preamble"]}) if init_mode == "customer": - seeded.append({"role": "customer", "text": _public_customer_opener(locale)}) + seeded.append({"role": "customer", "text": _customer_opener_text(persona, locale)}) session, resumed = s["sessions"].start( org_id=org_id, user_id=actor["id"], @@ -825,9 +828,7 @@ def send_message(gid: str, pid: str): outcome = finalized.get("outcome") return jsonify({ "reply": reply, - "messages": _safe_session_messages( - messages, redact_initial_customer=True, locale=slocale - ), + "messages": _safe_session_messages(messages), "finished": True, "outcome": outcome, "debrief": debrief, @@ -837,9 +838,7 @@ def send_message(gid: str, pid: str): s["sessions"].update(session["id"], messages=messages, internal=internal) return jsonify({ "reply": reply, - "messages": _safe_session_messages( - messages, redact_initial_customer=True, locale=slocale - ), + "messages": _safe_session_messages(messages), }) diff --git a/backend/tests/test_post_review_regressions.py b/backend/tests/test_post_review_regressions.py index bb1cfb9..c5a46a5 100644 --- a/backend/tests/test_post_review_regressions.py +++ b/backend/tests/test_post_review_regressions.py @@ -1006,19 +1006,19 @@ def test_upload_rejects_control_character_filename(client, user_store, login): assert response.status_code == 400, response.get_json() -def test_customer_opener_is_redacted_from_public_session_transcript( +def test_customer_opener_is_seeded_from_persona_and_shown_publicly( client, user_store, login ): _setup_default_admin(user_store, login) trainee = user_store.create_user( org_id="org-default", - username="opener-redaction-user", - password="opener-redaction-password", - name="Opener Redaction User", + username="opener-seed-user", + password="opener-seed-password", + name="Opener Seed User", role="user", must_setup=False, ) - token = login(trainee["username"], "opener-redaction-password")["token"] + token = login(trainee["username"], "opener-seed-password")["token"] group_store = client.application.extensions["group_store"] group = group_store.create(org_id="org-default", creator_id="admin", title="Opener") group_store.update( @@ -1030,24 +1030,26 @@ def test_customer_opener_is_redacted_from_public_session_transcript( { "id": "opener-persona", "name": "Opener Persona", - "opener": "PRIVATE_PERSONA_OPENER_MUST_NOT_APPEAR", + "opener": "สวัสดีค่ะ สนใจสินค้า ขอถามรายละเอียดหน่อยได้ไหมค่ะ", } ], ) response = client.post( f"/api/chat/{group['id']}/personas/opener-persona/chat/start", - json={"scenario": "social", "locale": "en"}, + json={"scenario": "social", "locale": "th"}, headers=_headers(token), ) assert response.status_code == 200, response.get_json() - session = response.get_json()["session"] - assert "PRIVATE_PERSONA_OPENER_MUST_NOT_APPEAR" not in str(session) - assert any(message["role"] == "customer" for message in session["messages"]) + payload = response.get_json() + session = payload["session"] + texts = [message["text"] for message in session["messages"] if message["role"] == "customer"] + assert "สวัสดีค่ะ สนใจสินค้า ขอถามรายละเอียดหน่อยได้ไหมค่ะ" in texts + assert payload["initiation_mode"] == "customer" -def test_session_serializer_replaces_legacy_customer_opener(): +def test_session_serializer_keeps_the_customer_opener_line(): view = serialize_session( { "id": "legacy-opener-session", @@ -1055,16 +1057,18 @@ def test_session_serializer_replaces_legacy_customer_opener(): "persona_id": "persona-1", "persona_name": "Customer", "status": "active", - "persona_meta": {"initiation_mode": "customer", "locale": "en"}, + "persona_meta": {"initiation_mode": "customer", "locale": "th"}, "messages": [ - {"role": "customer", "text": "PRIVATE_LEGACY_OPENER"}, - {"role": "seller", "text": "Hello"}, + {"role": "customer", "text": "สวัสดีครับ สนใจสินค้า ขอถามราคาหน่อยได้ไหมครับ"}, + {"role": "seller", "text": "สวัสดีค่ะ ยินดีบริการค่ะ"}, ], } ) - assert "PRIVATE_LEGACY_OPENER" not in str(view) - assert view["messages"][0]["role"] == "customer" + assert view["messages"][0] == { + "role": "customer", + "text": "สวัสดีครับ สนใจสินค้า ขอถามราคาหน่อยได้ไหมครับ", + } def test_board_ignores_non_ready_groups_and_unauthorized_sessions( diff --git a/backend/tests/test_security_review_blockers.py b/backend/tests/test_security_review_blockers.py index 8b5b90c..0cff9fc 100644 --- a/backend/tests/test_security_review_blockers.py +++ b/backend/tests/test_security_review_blockers.py @@ -879,7 +879,7 @@ def test_foreign_group_readiness_is_indistinguishable_from_missing(client, user_ assert response.get_json()["error"] == "group not found" -def test_session_serializer_filters_untrusted_system_notes_and_redacts_legacy_opener(): +def test_session_serializer_filters_untrusted_system_notes_and_keeps_customer_opener(): view = serialize_session( { "id": "session-safe", @@ -895,7 +895,7 @@ def test_session_serializer_filters_untrusted_system_notes_and_redacts_legacy_op "role": "system", "text": "⏳ ผ่านไป 2-3 สัปดาห์ ... ลูกค้าที่เคยสอบถามไปเงียบไประยะหนึ่ง ตอนนี้กลับมาติดต่ออีกครั้ง (พร้อมตัดสินใจมากขึ้น)", }, - {"role": "customer", "text": "legacy raw opener with hidden details"}, + {"role": "customer", "text": "สวัสดีครับ สนใจสินค้า ขอถามราคาหน่อยได้ไหมครับ"}, {"role": "seller", "text": "สวัสดีครับ ขอทราบความต้องการเพิ่มเติมได้ไหมครับ"}, ], } @@ -906,7 +906,7 @@ def test_session_serializer_filters_untrusted_system_notes_and_redacts_legacy_op assert any("ผ่านไป 2-3" in text for text in texts) assert view["messages"][1] == { "role": "customer", - "text": "ลูกค้าเริ่มต้นบทสนทนาแล้ว ลองทักและค้นหาความต้องการดูครับ", + "text": "สวัสดีครับ สนใจสินค้า ขอถามราคาหน่อยได้ไหมครับ", } assert view["scenario"] == "social" assert view["persona_meta"]["scenario"] == "social" diff --git a/backend/tests/test_sprint1_review_findings.py b/backend/tests/test_sprint1_review_findings.py index dcb801d..043896f 100644 --- a/backend/tests/test_sprint1_review_findings.py +++ b/backend/tests/test_sprint1_review_findings.py @@ -209,7 +209,7 @@ def test_session_serializer_omits_hidden_internal_state(): assert view["persona_meta"] == {"scenario": "social", "locale": "th"} assert view["messages"] == [{ "role": "customer", - "text": "ลูกค้าเริ่มต้นบทสนทนาแล้ว ลองทักและค้นหาความต้องการดูครับ", + "text": "hello", }] assert "/private" not in str(view) diff --git a/backend/tests/test_wave2_review_regressions.py b/backend/tests/test_wave2_review_regressions.py index 6cf05c5..5093eb8 100644 --- a/backend/tests/test_wave2_review_regressions.py +++ b/backend/tests/test_wave2_review_regressions.py @@ -28,14 +28,14 @@ def _oauth(client): return client.post("/api/auth/oauth", json={"provider": "google", "token": "verified-token"}) -def test_serializer_redacts_every_customer_message_before_first_seller_turn(): +def test_serializer_keeps_the_customer_openers_line_before_first_seller_turn(): + """The first customer line is spoken dialogue (the persona's real greeting) — public.""" public = serialize_session( { "id": "session-legacy", "persona_meta": {"locale": "en"}, "messages": [ - {"role": "customer", "text": "SECRET_OPENER_ONE"}, - {"role": "customer", "text": "SECRET_OPENER_TWO"}, + {"role": "customer", "text": "สวัสดีครับ สนใจสินค้า ขอถามราคาหน่อยได้ไหมครับ"}, {"role": "seller", "text": "Hello"}, {"role": "customer", "text": "Public reply"}, ], @@ -43,13 +43,11 @@ def test_serializer_redacts_every_customer_message_before_first_seller_turn(): ) texts = [row["text"] for row in public["messages"]] - assert "SECRET_OPENER_ONE" not in texts - assert "SECRET_OPENER_TWO" not in texts - assert texts[:2] == [ - "Hi, a customer has started the conversation.", - "Hi, a customer has started the conversation.", + assert texts == [ + "สวัสดีครับ สนใจสินค้า ขอถามราคาหน่อยได้ไหมครับ", + "Hello", + "Public reply", ] - assert texts[-1] == "Public reply" @pytest.mark.parametrize(