fix(chat): seed customer-initiated chats with the persona's real opener

Revert the over-redaction from 3c22d88: a customer-initiated
session (social / lead who messages in) now greets with the
persona's generated opener — a realistic 'สวัสดี สนใจ ขอถามราคา'
line — instead of the static 'ลูกค้าเริ่มต้นบทสนทนาแล้ว'
placeholder. The opener is spoken public dialogue by design
(OPENER RULE keeps pains/budget/personality out of it), so
remove the redact_initial_customer strip from serialize_session.

Phone/f2f (seller-initiated) is unchanged: no customer line,
the trainee opens the call. Untrusted system-note filtering and
internal-state/provider-path stripping are untouched.

Tests: 5 redaction regressions flipped to the new contract.
517 passed (3 pre-existing demo-date failures, unrelated).
This commit is contained in:
Macky
2026-10-01 22:54:30 +07:00
parent a7abc724be
commit a4aae466ea
5 changed files with 56 additions and 55 deletions

View File

@@ -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),
})

View File

@@ -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(

View File

@@ -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"

View File

@@ -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)

View File

@@ -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(