feat(ai): add rolling conversation summary memory
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import json
|
||||
|
||||
from services.shared.ai_context_summary import (
|
||||
render_context_summary_text,
|
||||
update_context_summary_from_assistant_turn,
|
||||
update_context_summary_from_user_turn,
|
||||
)
|
||||
|
||||
|
||||
def test_context_summary_tracks_city_and_branch_slot_for_schedule():
|
||||
summary = update_context_summary_from_user_turn(
|
||||
None,
|
||||
channel="voice",
|
||||
language="ru",
|
||||
customer_name="Ернур",
|
||||
text="Мне нужен график работы в городе Алмата.",
|
||||
now="2026-04-12T00:00:00+00:00",
|
||||
)
|
||||
|
||||
assert summary["channel"] == "voice"
|
||||
assert summary["customer_name"] == "Ернур"
|
||||
assert summary["active_intent"] == "schedule"
|
||||
assert summary["confirmed_facts"]["city"] == "Алмата"
|
||||
assert summary["open_slots"] == ["branch_or_address"]
|
||||
|
||||
|
||||
def test_context_summary_does_not_overwrite_meaningful_request_with_low_signal():
|
||||
summary = update_context_summary_from_user_turn(
|
||||
None,
|
||||
channel="voice",
|
||||
language="ru",
|
||||
customer_name="Ернур",
|
||||
text="Мне нужен график работы в городе Алмата.",
|
||||
now="2026-04-12T00:00:00+00:00",
|
||||
)
|
||||
|
||||
updated = update_context_summary_from_user_turn(
|
||||
json.dumps(summary, ensure_ascii=False),
|
||||
channel="voice",
|
||||
language="ru",
|
||||
customer_name="Ернур",
|
||||
text="Алло",
|
||||
now="2026-04-12T00:00:02+00:00",
|
||||
)
|
||||
|
||||
assert updated["active_request_text"] == "Мне нужен график работы в городе Алмата."
|
||||
assert updated["confirmed_facts"]["city"] == "Алмата"
|
||||
|
||||
|
||||
def test_context_summary_assistant_turn_keeps_slots_and_renders_text():
|
||||
summary = update_context_summary_from_user_turn(
|
||||
None,
|
||||
channel="telegram",
|
||||
language="ru",
|
||||
customer_name="Клиент",
|
||||
text="Хочу узнать график работы в городе Алмата",
|
||||
now="2026-04-12T00:00:00+00:00",
|
||||
)
|
||||
|
||||
updated = update_context_summary_from_assistant_turn(
|
||||
json.dumps(summary, ensure_ascii=False),
|
||||
language="ru",
|
||||
customer_name="Клиент",
|
||||
reply_text="Подскажите, какой филиал или адрес в этом городе вас интересует?",
|
||||
decision_intent="clarification",
|
||||
needs_handoff=False,
|
||||
handoff_reason=None,
|
||||
now="2026-04-12T00:00:03+00:00",
|
||||
)
|
||||
|
||||
rendered = render_context_summary_text(updated)
|
||||
assert updated["open_slots"] == ["branch_or_address"]
|
||||
assert "city=Алмата" in rendered
|
||||
assert "график работы" in rendered
|
||||
|
||||
|
||||
def test_context_summary_generic_clarification_does_not_downgrade_specific_slot():
|
||||
summary = update_context_summary_from_user_turn(
|
||||
None,
|
||||
channel="telegram",
|
||||
language="ru",
|
||||
customer_name="Клиент",
|
||||
text="Хочу узнать график работы в городе Алмата",
|
||||
now="2026-04-12T00:00:00+00:00",
|
||||
)
|
||||
|
||||
updated = update_context_summary_from_assistant_turn(
|
||||
json.dumps(summary, ensure_ascii=False),
|
||||
language="ru",
|
||||
customer_name="Клиент",
|
||||
reply_text="Уточните, пожалуйста, что именно нужно проверить или подсказать.",
|
||||
decision_intent="clarification",
|
||||
needs_handoff=False,
|
||||
handoff_reason=None,
|
||||
now="2026-04-12T00:00:03+00:00",
|
||||
)
|
||||
|
||||
assert updated["open_slots"] == ["branch_or_address"]
|
||||
assert updated["requested_clarifications"] == ["branch_or_address"]
|
||||
@@ -1590,6 +1590,71 @@ def test_turn_voice_session_early_plan_does_not_persist_partial_turns(monkeypatc
|
||||
session.close()
|
||||
|
||||
|
||||
def test_voice_llm_prompt_includes_context_summary_and_uses_12_segments():
|
||||
transcript_window = [
|
||||
SimpleNamespace(
|
||||
speaker="caller" if index % 2 == 0 else "assistant",
|
||||
text=f"segment {index}",
|
||||
sequence_no=index,
|
||||
source_type="voice_policy",
|
||||
barge_in_interrupted=False,
|
||||
created_at=utc_now_iso(),
|
||||
)
|
||||
for index in range(1, 16)
|
||||
]
|
||||
|
||||
messages = voice_module._voice_llm_prompt_messages(
|
||||
language="ru",
|
||||
customer=None,
|
||||
interaction=SimpleNamespace(
|
||||
interaction_id="int_voice_prompt",
|
||||
status="open",
|
||||
queue_id="que_voice",
|
||||
subject="schedule",
|
||||
customer_id="cus_voice_prompt",
|
||||
),
|
||||
transcript_text="Мне нужен график работы",
|
||||
transcript_window=transcript_window,
|
||||
conversation_summary_text="Customer name: Ернур | Active intent: schedule | Confirmed facts: city=Алмата",
|
||||
kb_results=[],
|
||||
name_value="Ернур",
|
||||
name_status="name_obtained",
|
||||
)
|
||||
|
||||
payload = json.loads(messages[1]["content"])
|
||||
assert payload["conversation_summary"].startswith("Customer name: Ернур")
|
||||
assert len(payload["history"]) == 12
|
||||
assert payload["history"][0]["sequence_no"] == 4
|
||||
|
||||
|
||||
def test_voice_postprocess_reply_uses_summary_context_when_raw_window_lost_topic():
|
||||
reply_text = voice_module._voice_postprocess_reply_text(
|
||||
language="ru",
|
||||
transcript_text="Сколько раз повторять тебе?",
|
||||
transcript_window=[
|
||||
SimpleNamespace(speaker="caller", text="Сколько раз повторять тебе?", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso()),
|
||||
],
|
||||
context_summary=json.dumps(
|
||||
{
|
||||
"customer_name": "Ернур",
|
||||
"active_intent": "schedule",
|
||||
"active_request_text": "Мне нужен график работы в городе Алмата",
|
||||
"confirmed_facts": {"city": "Алмата", "branch_hint": None, "service_hint": "график работы", "request_number": None},
|
||||
"open_slots": ["branch_or_address"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
reply_text="Пожалуйста, уточните, какая услуга вас интересует, чтобы я мог подсказать тариф.",
|
||||
kb_results=[],
|
||||
needs_handoff=False,
|
||||
)
|
||||
|
||||
normalized = reply_text.lower()
|
||||
assert "тариф" not in normalized
|
||||
assert "услуг" not in normalized
|
||||
assert "филиал" in normalized or "адрес" in normalized
|
||||
|
||||
|
||||
def test_ai_enqueue_creates_outbound_ai_reply_and_delivery_flow(monkeypatch):
|
||||
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
|
||||
monkeypatch.setenv("AI_PROVIDER", "stub")
|
||||
@@ -1639,6 +1704,61 @@ def test_ai_enqueue_creates_outbound_ai_reply_and_delivery_flow(monkeypatch):
|
||||
assert delivered.json()[-1]["telegram_message_id_external"] == "5551"
|
||||
|
||||
|
||||
def test_ai_enqueue_persists_internal_context_summary(monkeypatch):
|
||||
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
|
||||
monkeypatch.setenv("AI_PROVIDER", "stub")
|
||||
monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None)
|
||||
monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None)
|
||||
|
||||
telegram_client = TestClient(telegram_app)
|
||||
interaction_client = TestClient(interaction_app)
|
||||
ai_client = TestClient(ai_app)
|
||||
patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client)
|
||||
|
||||
created = create_inbound_thread(
|
||||
telegram_client,
|
||||
f"chat_ai_context_summary_{new_id('chat')}",
|
||||
"Хочу узнать график работы в городе Алмата",
|
||||
)
|
||||
enqueue = ai_client.post(
|
||||
f"/ai/telegram/threads/{created['thread_id']}/enqueue",
|
||||
headers=admin_headers(),
|
||||
json={"trigger_message_id": created["message_id"]},
|
||||
)
|
||||
|
||||
assert enqueue.status_code == 200
|
||||
assert enqueue.json()["status"] in {"done", "handoff_required", "running"}
|
||||
|
||||
deadline = time.time() + 2.0
|
||||
last_summary = None
|
||||
while time.time() < deadline:
|
||||
session = get_session()
|
||||
try:
|
||||
thread = session.execute(
|
||||
select(TelegramThreadRow).where(TelegramThreadRow.thread_id == created["thread_id"])
|
||||
).scalar_one()
|
||||
if not thread.ai_session_id:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
ai_session = session.execute(
|
||||
select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id)
|
||||
).scalar_one()
|
||||
last_summary = json.loads(ai_session.context_summary_json or "{}")
|
||||
if (
|
||||
last_summary.get("active_intent") == "schedule"
|
||||
and last_summary.get("confirmed_facts", {}).get("city") == "Алмата"
|
||||
):
|
||||
break
|
||||
finally:
|
||||
session.close()
|
||||
time.sleep(0.05)
|
||||
|
||||
assert last_summary is not None
|
||||
assert last_summary["active_intent"] == "schedule"
|
||||
assert last_summary["confirmed_facts"]["city"] == "Алмата"
|
||||
assert last_summary["open_slots"] == ["branch_or_address"]
|
||||
|
||||
|
||||
def test_ai_enqueue_relaxed_kb_search_answers_phrase_query(monkeypatch):
|
||||
monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1")
|
||||
monkeypatch.setenv("AI_PROVIDER", "stub")
|
||||
|
||||
@@ -64,3 +64,42 @@ def test_init_sql_schema_backfills_whatsapp_message_runtime_columns():
|
||||
assert "author_type" in columns
|
||||
assert "ix_whatsapp_messages_chat_external_unique" in indexes
|
||||
assert "idx_whatsapp_messages_next_delivery_attempt_at" in indexes
|
||||
|
||||
|
||||
def test_init_sql_schema_backfills_ai_session_context_summary_columns():
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("DROP TABLE IF EXISTS ai_sessions"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE ai_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
thread_id VARCHAR(64) NULL,
|
||||
interaction_id VARCHAR(64) NULL,
|
||||
customer_id VARCHAR(64) NULL,
|
||||
agent_profile VARCHAR(64) NOT NULL,
|
||||
language VARCHAR(16) NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
summary_text TEXT NOT NULL,
|
||||
last_user_message_id VARCHAR(64) NULL,
|
||||
last_ai_message_id VARCHAR(64) NULL,
|
||||
handoff_reason TEXT NULL,
|
||||
created_at VARCHAR(64) NOT NULL,
|
||||
updated_at VARCHAR(64) NOT NULL,
|
||||
closed_at VARCHAR(64) NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
init_sql_schema()
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = {item["name"] for item in inspector.get_columns("ai_sessions")}
|
||||
indexes = {item["name"] for item in inspector.get_indexes("ai_sessions")}
|
||||
|
||||
assert "context_summary_json" in columns
|
||||
assert "context_summary_updated_at" in columns
|
||||
assert "idx_ai_sessions_context_summary_updated_at" in indexes
|
||||
|
||||
Reference in New Issue
Block a user