deploy / deploy (push) Successful in 30s
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
241 lines
7.9 KiB
Python
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()
|