Files
call-center/services/kb_service/app.py
T
didar d2438b6954
deploy / deploy (push) Successful in 30s
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
2026-08-31 00:17:51 +05:00

241 lines
7.9 KiB
Python

from __future__ import annotations
import json
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import func, select
from services.shared.core import Role, new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.kb_localization import normalize_kb_language, resolve_article_group_id
from services.shared.kb_search import search_kb_rows
from services.shared.models import (
HealthResponse,
KBArticleCreate,
KBArticleOut,
KBArticleUpdate,
KBCategoryCreate,
KBCategoryOut,
)
from services.shared.security import require_roles
from services.shared.sql_init import init_sql_schema
from services.shared.sql_models import KBArticleRow, KBCategoryRow
app = FastAPI(title="kb-service", version="1.0.0")
init_sql_schema()
def _category_out(row: KBCategoryRow) -> KBCategoryOut:
return KBCategoryOut(
category_id=row.category_id,
name=row.name,
description=row.description,
created_at=row.created_at,
)
def _article_out(row: KBArticleRow) -> KBArticleOut:
return 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,
tags=json.loads(row.tags_json or "[]"),
created_at=row.created_at,
updated_at=row.updated_at,
)
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)
def _find_group_language_conflict(
session,
*,
article_group_id: str,
language: str,
exclude_article_id: str | None = None,
) -> KBArticleRow | None:
stmt = select(KBArticleRow).where(_article_group_expr() == article_group_id).where(KBArticleRow.language == language)
if exclude_article_id:
stmt = stmt.where(KBArticleRow.article_id != exclude_article_id)
return session.execute(stmt).scalar_one_or_none()
@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
return HealthResponse(status="ok", service="kb-service")
@app.post("/knowledge/categories", response_model=KBCategoryOut)
def create_category(
payload: KBCategoryCreate,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
) -> KBCategoryOut:
session = get_session()
try:
row = KBCategoryRow(
category_id=new_id("kbc"),
name=payload.name,
description=payload.description,
created_at=utc_now_iso(),
)
session.add(row)
session.commit()
session.refresh(row)
return _category_out(row)
finally:
session.close()
@app.get("/knowledge/categories", response_model=list[KBCategoryOut])
def list_categories() -> list[KBCategoryOut]:
session = get_session()
try:
rows = session.execute(select(KBCategoryRow).order_by(KBCategoryRow.id.asc())).scalars().all()
return [_category_out(r) for r in rows]
finally:
session.close()
@app.post("/knowledge/articles", response_model=KBArticleOut)
def create_article(
payload: KBArticleCreate,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
) -> KBArticleOut:
session = get_session()
try:
category = session.execute(
select(KBCategoryRow).where(KBCategoryRow.category_id == payload.category_id)
).scalar_one_or_none()
if not category:
raise HTTPException(status_code=400, detail="Unknown category_id")
now = utc_now_iso()
article_id = new_id("kba")
language = normalize_kb_language(payload.language)
article_group_id = resolve_article_group_id(article_id, payload.article_group_id)
conflict = _find_group_language_conflict(
session,
article_group_id=article_group_id,
language=language,
)
if conflict:
raise HTTPException(
status_code=409,
detail="Localized article already exists for this article_group_id and language",
)
row = KBArticleRow(
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,
tags_json=json.dumps(payload.tags, ensure_ascii=False),
created_at=now,
updated_at=now,
)
session.add(row)
session.commit()
session.refresh(row)
return _article_out(row)
finally:
session.close()
@app.get("/knowledge/articles/{article_id}", response_model=KBArticleOut)
def get_article(article_id: str) -> KBArticleOut:
session = get_session()
try:
row = session.execute(
select(KBArticleRow).where(KBArticleRow.article_id == article_id)
).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Article not found")
return _article_out(row)
finally:
session.close()
@app.patch("/knowledge/articles/{article_id}", response_model=KBArticleOut)
def update_article(
article_id: str,
payload: KBArticleUpdate,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)),
) -> KBArticleOut:
session = get_session()
try:
row = session.execute(
select(KBArticleRow).where(KBArticleRow.article_id == article_id)
).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Article not found")
data = payload.model_dump(exclude_none=True)
target_group_id = resolve_article_group_id(
row.article_id,
data.get("article_group_id", row.article_group_id),
)
target_language = normalize_kb_language(data.get("language", row.language))
conflict = _find_group_language_conflict(
session,
article_group_id=target_group_id,
language=target_language,
exclude_article_id=row.article_id,
)
if conflict:
raise HTTPException(
status_code=409,
detail="Localized article already exists for this article_group_id and language",
)
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:
row.title = data["title"]
if "body" in data:
row.body = data["body"]
if "tags" in data:
row.tags_json = json.dumps(data["tags"], ensure_ascii=False)
row.updated_at = utc_now_iso()
session.commit()
return _article_out(row)
finally:
session.close()
@app.get("/knowledge/search", response_model=list[KBArticleOut])
def search_articles(
q: str = "",
limit: int = 50,
language: str | None = None,
article_group_id: str | None = None,
) -> list[KBArticleOut]:
session = get_session()
try:
stmt = select(KBArticleRow).order_by(KBArticleRow.id.desc())
if language is not None:
stmt = stmt.where(KBArticleRow.language == normalize_kb_language(language))
if article_group_id:
stmt = stmt.where(_article_group_expr() == article_group_id.strip())
rows = session.execute(stmt).scalars().all()
matches = search_kb_rows(rows, q, limit=limit, empty_query_returns_all=True)
return [_article_out(row) for row in matches]
finally:
session.close()