feat(ai): add rolling conversation summary memory

This commit is contained in:
Yera All
2026-04-13 11:59:43 +05:00
parent 989bf9a0c1
commit 6b8f5d069b
14 changed files with 900 additions and 26 deletions
+99
View File
@@ -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"]
+120
View File
@@ -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")
+39
View File
@@ -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