Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bt_servant_engine/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Core package placeholder for upcoming onion architecture refactor."""

BT_SERVANT_VERSION = "1.2.17"
BT_SERVANT_VERSION = "1.2.18"
BT_SERVANT_RELEASES_URL = "https://github.com/unfoldingWord/bt-servant-engine/releases"

__all__ = ["BT_SERVANT_VERSION", "BT_SERVANT_RELEASES_URL"]
18 changes: 18 additions & 0 deletions bt_servant_engine/adapters/user_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ def set_user_response_language(user_id: str, language: str) -> None:
db.upsert(updated, cond)


def clear_user_response_language(user_id: str) -> None:
"""Remove the user's stored response language preference."""
q = Query()
db = get_user_db().table("users")
cond = cast(QueryLike, q.user_id == user_id)
existing_raw = db.get(cond)
existing = cast(Optional[Dict[str, Any]], existing_raw)
updated: Dict[str, Any] = (
existing.copy() if isinstance(existing, dict) else {"user_id": user_id}
)
updated["user_id"] = user_id
updated["response_language"] = None
db.upsert(updated, cond)


def get_user_agentic_strength(user_id: str) -> Optional[str]:
"""Get the user's preferred agentic strength, or None if not set."""
q = Query()
Expand Down Expand Up @@ -200,6 +215,9 @@ def get_response_language(self, user_id: str) -> str | None:
def set_response_language(self, user_id: str, language: str) -> None:
set_user_response_language(user_id, language)

def clear_response_language(self, user_id: str) -> None:
clear_user_response_language(user_id)

def get_agentic_strength(self, user_id: str) -> str | None:
return get_user_agentic_strength(user_id)

Expand Down
1 change: 1 addition & 0 deletions bt_servant_engine/core/intents.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class IntentType(str, Enum):
PERFORM_UNSUPPORTED_FUNCTION = "perform-unsupported-function"
RETRIEVE_SYSTEM_INFORMATION = "retrieve-system-information"
SET_RESPONSE_LANGUAGE = "set-response-language"
CLEAR_RESPONSE_LANGUAGE = "clear-response-language"
SET_AGENTIC_STRENGTH = "set-agentic-strength"
CONVERSE_WITH_BT_SERVANT = "converse-with-bt-servant"

Expand Down
100 changes: 93 additions & 7 deletions bt_servant_engine/core/language.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"""Language models and constants for the BT Servant application."""
"""Language models and helpers for the BT Servant application."""

from enum import Enum
import re
from typing import Optional, Union

from pydantic import BaseModel
from pydantic import BaseModel, field_validator

# Mapping of ISO 639-1 language codes to friendly names
# Mapping of ISO 639-1 language codes to friendly names.
# This is intentionally non-exhaustive and serves as a set of display overrides
# for the languages we reference most frequently in user-facing copy.
SUPPORTED_LANGUAGE_MAP = {
"en": "English",
"ar": "Arabic",
Expand All @@ -20,10 +24,13 @@
}

LANGUAGE_UNKNOWN = "UNKNOWN"
LANGUAGE_OTHER = "other"
_LANGUAGE_CODE_PATTERN = re.compile(r"^[a-z]{2}(?:-[a-z]{2})?$")
_LANGUAGE_NAME_LOOKUP = {name.lower(): code for code, name in SUPPORTED_LANGUAGE_MAP.items()}


class Language(str, Enum):
"""Supported ISO 639-1 language codes for responses/messages."""
"""Historical enum for legacy references (kept for compatibility)."""

ENGLISH = "en"
ARABIC = "ar"
Expand All @@ -39,16 +46,82 @@ class Language(str, Enum):
OTHER = "Other"


def _normalize_candidate(value: Union[str, "Language", None]) -> Optional[str]:
normalized: Optional[str] = None
if value is None:
normalized = None
elif isinstance(value, Language):
normalized = LANGUAGE_OTHER if value is Language.OTHER else value.value
else:
candidate = str(value).strip().lower()
if candidate:
if candidate == LANGUAGE_OTHER:
normalized = LANGUAGE_OTHER
elif _LANGUAGE_CODE_PATTERN.match(candidate):
normalized = candidate
return normalized


def normalize_language_code(value: Union[str, "Language", None]) -> Optional[str]:
"""Normalize input into a lowercase ISO 639-1 (optionally xx-yy) code."""
normalized = _normalize_candidate(value)
if normalized == LANGUAGE_OTHER:
return LANGUAGE_OTHER
return normalized


def normalized_or_other(value: Union[str, "Language", None]) -> str:
"""Normalize to an ISO code; fall back to 'other' when unknown."""
normalized = normalize_language_code(value)
return normalized or LANGUAGE_OTHER


def friendly_language_name(
code: Union[str, "Language", None], *, fallback: str = "that language"
) -> str:
"""Return a printable name for the given language code."""
normalized = normalize_language_code(code)
if not normalized or normalized == LANGUAGE_OTHER:
return fallback
return SUPPORTED_LANGUAGE_MAP.get(normalized, normalized.title())


def lookup_language_code(name: Optional[str]) -> Optional[str]:
"""Return a best-effort ISO code for a human-readable language name."""
if not name:
return None
normalized = name.strip().lower()
return _LANGUAGE_NAME_LOOKUP.get(normalized)


class ResponseLanguage(BaseModel):
"""Model for parsing/validating the detected response language."""

language: Language
language: str

@field_validator("language", mode="before")
@classmethod
def _coerce_language(cls, value: Union[str, "Language"]) -> str:
normalized = normalized_or_other(value)
if normalized == LANGUAGE_OTHER:
return LANGUAGE_OTHER
if not normalized:
raise ValueError("language must be an ISO 639-1 code or 'Other'")
return normalized


class MessageLanguage(BaseModel):
"""Model for parsing/validating the detected language of a message."""

language: Language
language: str

@field_validator("language", mode="before")
@classmethod
def _coerce_language(cls, value: Union[str, "Language"]) -> str:
normalized = normalized_or_other(value)
if not normalized or normalized == LANGUAGE_OTHER:
raise ValueError("message language must be an ISO 639-1 code")
return normalized


class TranslatedPassage(BaseModel):
Expand All @@ -63,14 +136,27 @@ class TranslatedPassage(BaseModel):
header_book: str
header_suffix: str
body: str
content_language: Language
content_language: str

@field_validator("content_language", mode="before")
@classmethod
def _coerce_content_language(cls, value: Union[str, "Language"]) -> str:
normalized = normalize_language_code(value)
if not normalized or normalized == LANGUAGE_OTHER:
raise ValueError("content_language must be an ISO 639-1 code")
return normalized


__all__ = [
"SUPPORTED_LANGUAGE_MAP",
"LANGUAGE_UNKNOWN",
"LANGUAGE_OTHER",
"Language",
"ResponseLanguage",
"MessageLanguage",
"TranslatedPassage",
"normalize_language_code",
"normalized_or_other",
"friendly_language_name",
"lookup_language_code",
]
4 changes: 4 additions & 0 deletions bt_servant_engine/core/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ def set_response_language(self, user_id: str, language: str) -> None:
"""Persist the response language preference."""
...

def clear_response_language(self, user_id: str) -> None:
"""Remove any stored response language preference."""
...

def get_agentic_strength(self, user_id: str) -> str | None:
"""Return the stored agentic strength preference."""
...
Expand Down
18 changes: 16 additions & 2 deletions bt_servant_engine/services/brain_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from openai import OpenAI

from bt_servant_engine.core.config import config
from bt_servant_engine.core.language import SUPPORTED_LANGUAGE_MAP as supported_language_map
from bt_servant_engine.core.logging import get_logger
from bt_servant_engine.core.ports import ChromaPort, UserStatePort
from bt_servant_engine.services.openai_utils import (
Expand Down Expand Up @@ -58,8 +57,11 @@
from bt_servant_engine.services.intents.settings_intents import (
AgenticStrengthDependencies,
AgenticStrengthRequest,
ClearResponseLanguageDependencies,
ClearResponseLanguageRequest,
ResponseLanguageDependencies,
ResponseLanguageRequest,
clear_response_language as clear_response_language_impl,
set_agentic_strength as set_agentic_strength_impl,
set_response_language as set_response_language_impl,
)
Expand Down Expand Up @@ -406,12 +408,23 @@ def set_response_language(state: Any) -> dict:
chat_history=s["user_chat_history"],
)
dependencies = ResponseLanguageDependencies(
supported_language_map=supported_language_map,
set_user_response_language=user_state.set_response_language,
)
return set_response_language_impl(request, dependencies)


def clear_response_language(state: Any) -> dict:
"""Clear the user's stored response language preference."""

s = _brain_state(state)
user_state = _user_state_port()
request = ClearResponseLanguageRequest(user_id=s["user_id"])
dependencies = ClearResponseLanguageDependencies(
clear_user_response_language=user_state.clear_response_language
)
return clear_response_language_impl(request, dependencies)


def set_agentic_strength(state: Any) -> dict:
"""Detect and persist the user's preferred agentic strength."""

Expand Down Expand Up @@ -919,4 +932,5 @@ def _sample_for_language_detection(text: str) -> str:
"handle_translate_scripture",
# Helper functions (for test compatibility)
"resolve_selection_for_single_book",
"clear_response_language",
]
7 changes: 7 additions & 0 deletions bt_servant_engine/services/brain_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
# Higher values = higher priority = processed first when multiple intents detected
INTENT_PRIORITY: Dict[IntentType, int] = {
# Settings intents: Always process first to configure the session
IntentType.CLEAR_RESPONSE_LANGUAGE: 101,
IntentType.SET_RESPONSE_LANGUAGE: 100,
IntentType.SET_AGENTIC_STRENGTH: 99,
# Scripture retrieval: Get the text before analyzing it
Expand Down Expand Up @@ -65,6 +66,7 @@
IntentType.RETRIEVE_SCRIPTURE: "handle_retrieve_scripture_node",
IntentType.LISTEN_TO_SCRIPTURE: "handle_listen_to_scripture_node",
IntentType.SET_RESPONSE_LANGUAGE: "set_response_language_node",
IntentType.CLEAR_RESPONSE_LANGUAGE: "clear_response_language_node",
IntentType.SET_AGENTIC_STRENGTH: "set_agentic_strength_node",
IntentType.PERFORM_UNSUPPORTED_FUNCTION: "handle_unsupported_function_node",
IntentType.RETRIEVE_SYSTEM_INFORMATION: "handle_system_information_request_node",
Expand Down Expand Up @@ -562,6 +564,10 @@ def _should_show_translation_progress(state: Any) -> bool:
"set_response_language_node",
wrap_node_with_timing(brain_nodes.set_response_language, "set_response_language_node"),
)
builder.add_node(
"clear_response_language_node",
wrap_node_with_timing(brain_nodes.clear_response_language, "clear_response_language_node"),
)
builder.add_node(
"set_agentic_strength_node",
wrap_node_with_timing(brain_nodes.set_agentic_strength, "set_agentic_strength_node"),
Expand Down Expand Up @@ -714,6 +720,7 @@ def _should_show_translation_progress(state: Any) -> bool:
builder.add_conditional_edges("determine_intents_node", process_intents)
builder.add_edge("query_vector_db_node", "query_open_ai_node")
builder.add_edge("set_response_language_node", "translate_responses_node")
builder.add_edge("clear_response_language_node", "translate_responses_node")
builder.add_edge("set_agentic_strength_node", "translate_responses_node")
# After chunking, finish. Do not loop back to translate, which can recreate
# the long message and trigger an infinite chunk cycle.
Expand Down
1 change: 1 addition & 0 deletions bt_servant_engine/services/continuation_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
IntentType.PERFORM_UNSUPPORTED_FUNCTION: "help with that request",
IntentType.RETRIEVE_SYSTEM_INFORMATION: "provide system information",
IntentType.SET_RESPONSE_LANGUAGE: "set your response language",
IntentType.CLEAR_RESPONSE_LANGUAGE: "clear your response language preference",
IntentType.SET_AGENTIC_STRENGTH: "adjust your agentic strength preference",
IntentType.CONVERSE_WITH_BT_SERVANT: "continue our conversation",
}
Expand Down
8 changes: 2 additions & 6 deletions bt_servant_engine/services/intents/fia_intents.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from openai.types.responses.easy_input_message_param import EasyInputMessageParam

from bt_servant_engine.core.intents import IntentType
from bt_servant_engine.core.language import SUPPORTED_LANGUAGE_MAP as supported_language_map
from bt_servant_engine.core.logging import get_logger
from bt_servant_engine.services.intents.simple_intents import (
BOILER_PLATE_AVAILABLE_FEATURES_MESSAGE,
Expand Down Expand Up @@ -102,11 +101,8 @@ def consult_fia_resources(request: FIARequest, dependencies: FIADependencies) ->


def _resolve_candidate_language(request: FIARequest) -> str:
candidate = (request.user_response_language or request.query_language or "en").lower()
if candidate not in supported_language_map:
logger.info("[consult-fia] unsupported language '%s'; defaulting to English", candidate)
return "en"
return candidate
candidate = (request.user_response_language or request.query_language or "en").strip().lower()
return candidate or "en"


def _gather_vector_documents(
Expand Down
13 changes: 13 additions & 0 deletions bt_servant_engine/services/intents/followup_questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@
"id": "Apa lagi yang bisa saya bantu hari ini?",
"nl": "Waarmee kan ik u vandaag nog meer helpen?",
},
IntentType.CLEAR_RESPONSE_LANGUAGE: {
"en": "Would you like me to set a new response language?",
"es": "¿Quiere que configure un nuevo idioma de respuesta?",
"fr": "Souhaitez-vous que je définisse une nouvelle langue de réponse ?",
"pt": "Gostaria que eu definisse um novo idioma de resposta?",
"sw": "Je, ungependa nikaweke lugha mpya ya majibu?",
"ar": "هل تريد مني تحديد لغة استجابة جديدة؟",
"hi": "क्या आप चाहते हैं कि मैं नया उत्तर देने का भाषा तय कर दूं?",
"zh": "需要我设置一个新的回复语言吗?",
"ru": "Хотите, чтобы я установил новый язык ответов?",
"id": "Apakah Anda ingin saya menetapkan bahasa tanggapan baru?",
"nl": "Wilt u dat ik een nieuwe antwoordtaal instel?",
},
IntentType.SET_AGENTIC_STRENGTH: {
"en": "Is there anything else I can assist you with?",
"es": "¿Hay algo más en lo que pueda ayudarle?",
Expand Down
Loading