From d2438b69547478f1e48d246bedad1ee957711dd2 Mon Sep 17 00:00:00 2001 From: didar Date: Mon, 31 Aug 2026 00:17:51 +0500 Subject: [PATCH] feat: canonical intent taxonomy for AI operator (kb_answer -> intent_code) Centralizes fixed control intents and adds a data-driven intent_code field on kb_articles so many phrasings of the same FAQ question resolve to one stable code (e.g. VOUCHER_ACTIVATION) instead of a free-form, unvalidated string the LLM invented on the fly. - services/shared/intents.py: CONTROL_INTENTS + normalize_intent() - kb_articles.intent_code column (ORM + dev/sqlite runtime compat + migrations/sql/0034_* for postgres/sqlite) - kb_service CRUD exposes intent_code - orchestrator surfaces intent_code to the LLM and validates its intent output against control intents + the KB codes shown that turn - voice.py: _voice_early_intent_bucket renamed to _voice_ack_topic_bucket to stop it being conflated with the canonical FAQ intent --- .../sql/0034_kb_intent_code_postgres.sql | 2 + migrations/sql/0034_kb_intent_code_sqlite.sql | 2 + services/ai_orchestrator_service/app.py | 21 ++++- .../operator_persona.py | 7 +- services/ai_orchestrator_service/voice.py | 19 ++++- services/kb_service/app.py | 8 ++ services/shared/intents.py | 39 +++++++++ services/shared/kb_search.py | 1 + services/shared/models.py | 2 + services/shared/sql_init.py | 8 ++ services/shared/sql_models.py | 1 + tests/test_ai_orchestrator_service.py | 84 +++++++++++++++++++ tests/test_intents.py | 34 ++++++++ tests/test_stage3_services.py | 40 +++++++++ 14 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 migrations/sql/0034_kb_intent_code_postgres.sql create mode 100644 migrations/sql/0034_kb_intent_code_sqlite.sql create mode 100644 services/shared/intents.py create mode 100644 tests/test_intents.py diff --git a/migrations/sql/0034_kb_intent_code_postgres.sql b/migrations/sql/0034_kb_intent_code_postgres.sql new file mode 100644 index 0000000..02fc64d --- /dev/null +++ b/migrations/sql/0034_kb_intent_code_postgres.sql @@ -0,0 +1,2 @@ +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS intent_code TEXT; +CREATE INDEX IF NOT EXISTS idx_kb_articles_intent_code ON kb_articles(intent_code); diff --git a/migrations/sql/0034_kb_intent_code_sqlite.sql b/migrations/sql/0034_kb_intent_code_sqlite.sql new file mode 100644 index 0000000..ba49988 --- /dev/null +++ b/migrations/sql/0034_kb_intent_code_sqlite.sql @@ -0,0 +1,2 @@ +ALTER TABLE kb_articles ADD COLUMN intent_code TEXT; +CREATE INDEX IF NOT EXISTS idx_kb_articles_intent_code ON kb_articles(intent_code); diff --git a/services/ai_orchestrator_service/app.py b/services/ai_orchestrator_service/app.py index bfa14fa..b3111cf 100644 --- a/services/ai_orchestrator_service/app.py +++ b/services/ai_orchestrator_service/app.py @@ -9,7 +9,7 @@ import os import re import time from threading import Lock -from typing import Any +from typing import Any, Iterable import httpx from fastapi import Depends, FastAPI, HTTPException, Query @@ -23,6 +23,7 @@ from services.shared.ai_context_summary import ( update_context_summary_from_assistant_turn, update_context_summary_from_user_turn, ) +from services.shared.intents import normalize_intent from services.shared.kb_localization import normalize_kb_language from services.shared.kb_search import search_kb_rows from services.shared.models import ( @@ -2315,6 +2316,7 @@ def _openai_prompt( "article_id": article.article_id, "title": article.title, "snippet": _article_snippet(article), + "intent_code": getattr(article, "intent_code", None), } for article in kb_results ] @@ -2410,10 +2412,15 @@ def _openai_compatible_decision( ) -def _sanitize_decision(raw: dict[str, Any], *, fallback_language: str) -> dict[str, Any]: +def _sanitize_decision( + raw: dict[str, Any], + *, + fallback_language: str, + known_topic_codes: Iterable[str] = (), +) -> dict[str, Any]: decision = { "language": str(raw.get("language") or fallback_language or "ru"), - "intent": str(raw.get("intent") or "unknown"), + "intent": normalize_intent(raw.get("intent"), known_topic_codes=known_topic_codes), "reply_text": str(raw.get("reply_text") or "").strip(), "extracted_name": str(raw.get("extracted_name") or "").strip() or None, "confidence": float(raw.get("confidence") or 0.0), @@ -2611,7 +2618,13 @@ def _decide_reply( raw["_model"] = _ai_model() raw["_latency_ms"] = 1 raw["_finish_reason"] = "stop" - return _sanitize_decision(raw, fallback_language=language) + return _sanitize_decision( + raw, + fallback_language=language, + known_topic_codes=[ + code for article in kb_results if (code := getattr(article, "intent_code", None)) + ], + ) def _update_ai_session_context_summary_from_user_turn( diff --git a/services/ai_orchestrator_service/operator_persona.py b/services/ai_orchestrator_service/operator_persona.py index 2d92647..e016cc3 100644 --- a/services/ai_orchestrator_service/operator_persona.py +++ b/services/ai_orchestrator_service/operator_persona.py @@ -178,5 +178,10 @@ def operator_system_prompt(*, language: str, channel_label: str, is_voice: bool, "set needs_handoff=true. " f"{delivery_hint} " "Return only a JSON object with keys: language, intent, reply_text, extracted_name, confidence, needs_handoff, " - "handoff_reason, case_action, kb_refs. case_action must be one of none, close, escalate, keep_open." + "handoff_reason, case_action, kb_refs. case_action must be one of none, close, escalate, keep_open. " + "For `intent`: if your reply is grounded in one of the provided kb_results, set intent to that snippet's " + "intent_code exactly as given (do not translate, reformat, or invent your own code). If no kb_results were " + "used, use one of these fixed values as appropriate: identity_question, handoff_request, sensitive_request, " + "resolution_confirmed, clarification, kb_answer, unknown. Never invent a new intent value outside of these " + "two sources — the platform discards anything else." ) diff --git a/services/ai_orchestrator_service/voice.py b/services/ai_orchestrator_service/voice.py index 5e2b9bf..e4c4cfe 100644 --- a/services/ai_orchestrator_service/voice.py +++ b/services/ai_orchestrator_service/voice.py @@ -1359,7 +1359,13 @@ def _voice_v2_enabled(metadata: dict[str, Any] | None = None) -> bool: return _voice_policy_mode() in {"v2_fast_conversational", "v2_streaming_duplex"} -def _voice_early_intent_bucket(text: str) -> str: +def _voice_ack_topic_bucket(text: str) -> str: + """Coarse keyword heuristic used only to pick an ack phrase / early clarifying + question (see _voice_ack_kind_for_intent and _voice_early_plan) while the real, + KB-grounded decision is still in flight. This is NOT the canonical FAQ intent + (see services.shared.intents) and must never be echoed back as the final + decision's `intent` value. + """ normalized = " ".join(str(text or "").strip().lower().split()) if not normalized: return "unknown" @@ -1509,7 +1515,7 @@ def _voice_v2_metadata( if not _voice_v2_enabled(request_metadata): return {} payload = request_metadata if isinstance(request_metadata, dict) else {} - early_intent = _voice_early_intent_bucket(transcript_text) + early_intent = _voice_ack_topic_bucket(transcript_text) metadata: dict[str, Any] = { "voice_v2_enabled": True, "early_intent": early_intent, @@ -1668,6 +1674,7 @@ def _voice_llm_prompt_messages( "article_id": article.article_id, "title": article.title, "snippet": app._article_snippet(article, limit=240), + "intent_code": getattr(article, "intent_code", None), } for article in kb_results[:3] ] @@ -1767,7 +1774,13 @@ def _voice_llm_decision( ) except Exception: return None - decision = app._sanitize_decision(raw, fallback_language=language) + decision = app._sanitize_decision( + raw, + fallback_language=language, + known_topic_codes=[ + code for article in kb_results if (code := getattr(article, "intent_code", None)) + ], + ) if not str(decision.get("reply_text") or "").strip(): decision["reply_text"] = _voice_generic_prompt(language) if decision.get("needs_handoff") and not decision.get("handoff_reason"): diff --git a/services/kb_service/app.py b/services/kb_service/app.py index e76023d..6eca843 100644 --- a/services/kb_service/app.py +++ b/services/kb_service/app.py @@ -40,6 +40,7 @@ def _article_out(row: KBArticleRow) -> KBArticleOut: article_id=row.article_id, category_id=row.category_id, article_group_id=resolve_article_group_id(row.article_id, row.article_group_id), + intent_code=row.intent_code, language=normalize_kb_language(row.language), title=row.title, body=row.body, @@ -49,6 +50,10 @@ def _article_out(row: KBArticleRow) -> KBArticleOut: ) +def _normalize_intent_code(value: str | None) -> str | None: + return str(value or "").strip().upper() or None + + def _article_group_expr(): return func.coalesce(KBArticleRow.article_group_id, KBArticleRow.article_id) @@ -133,6 +138,7 @@ def create_article( article_id=article_id, category_id=payload.category_id, article_group_id=article_group_id, + intent_code=_normalize_intent_code(payload.intent_code), language=language, title=payload.title, body=payload.body, @@ -196,6 +202,8 @@ def update_article( if "article_group_id" in data: row.article_group_id = target_group_id + if "intent_code" in data: + row.intent_code = _normalize_intent_code(data["intent_code"]) if "language" in data: row.language = target_language if "title" in data: diff --git a/services/shared/intents.py b/services/shared/intents.py new file mode 100644 index 0000000..3ec2170 --- /dev/null +++ b/services/shared/intents.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Iterable + +# Fixed, code-level conversational signals — not FAQ topics. These strings are +# already used as intent literals across ai_orchestrator_service/app.py, +# voice.py, and asserted directly in tests; centralized here rather than +# renamed so every call site validates against the same set. +CONTROL_INTENTS: frozenset[str] = frozenset( + { + "identity_question", + "handoff_request", + "sensitive_request", + "resolution_confirmed", + "clarification", + "kb_answer", + "unknown", + } +) + +UNKNOWN_INTENT = "unknown" + + +def normalize_intent(raw: str | None, *, known_topic_codes: Iterable[str] = ()) -> str: + """Validate a model-produced intent against control intents and KB topic codes. + + `known_topic_codes` are the `intent_code` values of the KB articles actually + shown to the model for this turn — anything else the model invents collapses + to UNKNOWN_INTENT rather than being trusted verbatim. + """ + candidate = str(raw or "").strip() + if not candidate: + return UNKNOWN_INTENT + if candidate in CONTROL_INTENTS: + return candidate + normalized_topic_codes = {str(code or "").strip().upper() for code in known_topic_codes if str(code or "").strip()} + if candidate.upper() in normalized_topic_codes: + return candidate.upper() + return UNKNOWN_INTENT diff --git a/services/shared/kb_search.py b/services/shared/kb_search.py index dbbdd2d..1eca81e 100644 --- a/services/shared/kb_search.py +++ b/services/shared/kb_search.py @@ -25,6 +25,7 @@ class KBSearchRow(Protocol): title: str body: str tags_json: str + intent_code: str | None T = TypeVar("T", bound=KBSearchRow) diff --git a/services/shared/models.py b/services/shared/models.py index 532c647..00b2bd9 100644 --- a/services/shared/models.py +++ b/services/shared/models.py @@ -983,6 +983,7 @@ class KBCategoryOut(KBCategoryCreate): class KBArticleCreate(BaseModel): category_id: str article_group_id: str | None = None + intent_code: str | None = None language: str = "ru" title: str body: str @@ -991,6 +992,7 @@ class KBArticleCreate(BaseModel): class KBArticleUpdate(BaseModel): article_group_id: str | None = None + intent_code: str | None = None language: str | None = None title: str | None = None body: str | None = None diff --git a/services/shared/sql_init.py b/services/shared/sql_init.py index 4db4ec6..cffa611 100644 --- a/services/shared/sql_init.py +++ b/services/shared/sql_init.py @@ -583,6 +583,7 @@ def _apply_runtime_schema_compatibility() -> None: if "kb_articles" in table_names: columns = _table_columns(inspector, "kb_articles") _add_column_if_missing(conn, columns, "kb_articles", "article_group_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "kb_articles", "intent_code", "VARCHAR(64)") _add_column_if_missing(conn, columns, "kb_articles", "language", "VARCHAR(8) DEFAULT 'ru'") if "language" in columns: conn.execute( @@ -619,6 +620,13 @@ def _apply_runtime_schema_compatibility() -> None: "ON kb_articles(language)" ) ) + if "idx_kb_articles_intent_code" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_kb_articles_intent_code " + "ON kb_articles(intent_code)" + ) + ) if "sales_automation_tasks" in table_names: columns = _table_columns(inspector, "sales_automation_tasks") diff --git a/services/shared/sql_models.py b/services/shared/sql_models.py index b809545..72c3282 100644 --- a/services/shared/sql_models.py +++ b/services/shared/sql_models.py @@ -700,6 +700,7 @@ class KBArticleRow(Base): article_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) category_id: Mapped[str] = mapped_column(String(64), index=True) article_group_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + intent_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) language: Mapped[str] = mapped_column(String(8), index=True, default="ru") title: Mapped[str] = mapped_column(String(512), index=True) body: Mapped[str] = mapped_column(Text) diff --git a/tests/test_ai_orchestrator_service.py b/tests/test_ai_orchestrator_service.py index a48e666..a474649 100644 --- a/tests/test_ai_orchestrator_service.py +++ b/tests/test_ai_orchestrator_service.py @@ -414,6 +414,7 @@ def seed_kb_article( *, language: str = "ru", article_group_id: str | None = None, + intent_code: str | None = None, ) -> dict[str, str]: session = get_session() try: @@ -434,6 +435,7 @@ def seed_kb_article( article_id=article_id, category_id=category_id, article_group_id=resolved_group_id, + intent_code=intent_code, language=language, title=title, body=body, @@ -1347,6 +1349,88 @@ def test_voice_llm_guarded_decision_uses_operator_style_without_ai_or_kb(monkeyp assert "Do not say or imply that you are an AI" in system_prompt +def test_voice_llm_decision_echoes_kb_article_intent_code(monkeypatch): + monkeypatch.setenv("AI_VOICE_POLICY_MODE", "llm_guarded") + monkeypatch.setattr(ai_module, "_ai_provider", lambda: "openai_compatible") + + def _fake_structured(messages, **kwargs): + del messages + return { + "language": "ru", + "intent": "voucher_activation", + "reply_text": "Подтвердите СМС с номера 1414 командой 21*1, затем завершите активацию в eGov.", + "confidence": 0.9, + "needs_handoff": False, + "handoff_reason": None, + "case_action": "keep_open", + "kb_refs": ["kba_voucher_1"], + "_model": "gpt-test", + "_latency_ms": 30, + "_finish_reason": "stop", + } + + monkeypatch.setattr(ai_module, "_request_structured_model_decision", _fake_structured) + + kb_article = SimpleNamespace( + article_id="kba_voucher_1", + title="Активация ваучера", + body="Подтвердите СМС 1414 командой 21*1, затем перейдите по ссылке и завершите в eGov Mobile.", + intent_code="VOUCHER_ACTIVATION", + ) + decision = voice_module._voice_decision( + language="ru", + customer=None, + interaction=SimpleNamespace(interaction_id="int_voice_voucher", customer_id=None, status="new", queue_id="que_voice", subject="voucher"), + transcript_text="Что делать с СМС от 1414?", + transcript_window=[SimpleNamespace(speaker="caller", text="Что делать с СМС от 1414?", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso())], + kb_results=[kb_article], + disclosure_required=False, + ) + + assert decision["intent"] == "VOUCHER_ACTIVATION" + + +def test_voice_llm_decision_rejects_invented_intent_not_in_kb_results(monkeypatch): + monkeypatch.setenv("AI_VOICE_POLICY_MODE", "llm_guarded") + monkeypatch.setattr(ai_module, "_ai_provider", lambda: "openai_compatible") + + def _fake_structured(messages, **kwargs): + del messages + return { + "language": "ru", + "intent": "totally_made_up_intent", + "reply_text": "Подтвердите СМС с номера 1414 командой 21*1.", + "confidence": 0.9, + "needs_handoff": False, + "handoff_reason": None, + "case_action": "keep_open", + "kb_refs": ["kba_voucher_2"], + "_model": "gpt-test", + "_latency_ms": 30, + "_finish_reason": "stop", + } + + monkeypatch.setattr(ai_module, "_request_structured_model_decision", _fake_structured) + + kb_article = SimpleNamespace( + article_id="kba_voucher_2", + title="Активация ваучера", + body="Подтвердите СМС 1414 командой 21*1.", + intent_code="VOUCHER_ACTIVATION", + ) + decision = voice_module._voice_decision( + language="ru", + customer=None, + interaction=SimpleNamespace(interaction_id="int_voice_voucher_2", customer_id=None, status="new", queue_id="que_voice", subject="voucher"), + transcript_text="Куда отправлять 21*1?", + transcript_window=[SimpleNamespace(speaker="caller", text="Куда отправлять 21*1?", sequence_no=1, source_type="voice_asr", barge_in_interrupted=False, created_at=utc_now_iso())], + kb_results=[kb_article], + disclosure_required=False, + ) + + assert decision["intent"] == "unknown" + + def test_voice_v2_fast_conversational_adds_ack_metadata_and_compacts_reply(monkeypatch): monkeypatch.setenv("AI_VOICE_POLICY_MODE", "v2_fast_conversational") monkeypatch.setattr(ai_module, "_ai_provider", lambda: "openai_compatible") diff --git a/tests/test_intents.py b/tests/test_intents.py new file mode 100644 index 0000000..097085b --- /dev/null +++ b/tests/test_intents.py @@ -0,0 +1,34 @@ +from services.shared.intents import CONTROL_INTENTS, UNKNOWN_INTENT, normalize_intent + + +def test_control_intent_passes_through_unchanged(): + assert normalize_intent("handoff_request") == "handoff_request" + assert normalize_intent("kb_answer", known_topic_codes=["VOUCHER_ACTIVATION"]) == "kb_answer" + + +def test_matching_topic_code_passes_through_case_insensitively(): + assert normalize_intent("voucher_activation", known_topic_codes=["VOUCHER_ACTIVATION"]) == "VOUCHER_ACTIVATION" + assert normalize_intent(" Voucher_Activation ", known_topic_codes=["voucher_activation"]) == "VOUCHER_ACTIVATION" + + +def test_unknown_topic_code_falls_back_to_unknown(): + assert normalize_intent("made_up_intent", known_topic_codes=["VOUCHER_ACTIVATION"]) == UNKNOWN_INTENT + assert normalize_intent("voucher_activation", known_topic_codes=[]) == UNKNOWN_INTENT + + +def test_empty_or_missing_intent_falls_back_to_unknown(): + assert normalize_intent(None) == UNKNOWN_INTENT + assert normalize_intent("") == UNKNOWN_INTENT + assert normalize_intent(" ") == UNKNOWN_INTENT + + +def test_control_intents_frozenset_matches_documented_values(): + assert CONTROL_INTENTS == { + "identity_question", + "handoff_request", + "sensitive_request", + "resolution_confirmed", + "clarification", + "kb_answer", + "unknown", + } diff --git a/tests/test_stage3_services.py b/tests/test_stage3_services.py index c9602a4..eeaadb8 100644 --- a/tests/test_stage3_services.py +++ b/tests/test_stage3_services.py @@ -40,6 +40,46 @@ def test_kb_lite_search(): assert len(search.json()) >= 1 +def test_kb_article_intent_code_round_trips_through_create_get_update(): + client = TestClient(kb_app) + headers = {"X-User": "analyst", "X-Role": "analyst"} + + cat = client.post( + "/knowledge/categories", + json={"name": "Vouchers", "description": "Voucher help"}, + headers=headers, + ) + assert cat.status_code == 200 + category_id = cat.json()["category_id"] + + created = client.post( + "/knowledge/articles", + json={ + "category_id": category_id, + "title": "Активация ваучера", + "body": "Подтвердите СМС 1414 командой 21*1.", + "tags": ["voucher"], + "intent_code": "voucher_activation", + }, + headers=headers, + ) + assert created.status_code == 200 + assert created.json()["intent_code"] == "VOUCHER_ACTIVATION" + article_id = created.json()["article_id"] + + fetched = client.get(f"/knowledge/articles/{article_id}") + assert fetched.status_code == 200 + assert fetched.json()["intent_code"] == "VOUCHER_ACTIVATION" + + updated = client.patch( + f"/knowledge/articles/{article_id}", + json={"intent_code": "voucher_activation_v2"}, + headers=headers, + ) + assert updated.status_code == 200 + assert updated.json()["intent_code"] == "VOUCHER_ACTIVATION_V2" + + def test_kb_lite_search_ranks_title_over_body_only_matches(): client = TestClient(kb_app) headers = {"X-User": "analyst", "X-Role": "analyst"}