Skip to content

Commit 23cafaa

Browse files
committed
feat: add language detection functionality with robust parsing and structured output
1 parent fdc4dce commit 23cafaa

10 files changed

Lines changed: 417 additions & 6 deletions

File tree

libs/README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,14 @@ Uploaded documents are required to contain the following metadata:
9090
| composed_retriever | [`rag_core_api.retriever.retriever.Retriever`](./rag-core-api/src/rag_core_api/retriever/retriever.py) | [`rag_core_api.impl.retriever.composite_retriever.CompositeRetriever`](./rag-core-api/src/rag_core_api/impl/retriever/composite_retriever.py) | Handles retrieval, re-ranking, etc. |
9191
| large_language_model | `langchain_core.language_models.llms.BaseLLM` | `langchain_community.llms.vllm.VLLMOpenAI`, `langchain_community.llms.Ollama` or `langchain_community.llms.FakeListLLM` | The LLm that is used for all LLM tasks. The default depends on the value of `rag_core_lib.impl.settings.rag_class_types_settings.RAGClassTypeSettings.llm_type`. The FakeListLLM is used for testing |
9292
| prompt | `str` | [`rag_core_api.prompt_templates.answer_generation_prompt.ANSWER_GENERATION_PROMPT`](./rag-core-api/src/rag_core_api/prompt_templates/answer_generation_prompt.py) | The prompt used for answering the question. |
93-
| rephrasing_prompt | `str` | [`rag_core_api.prompt_templates.question_rephrasing_prompt.ANSWER_REPHRASING_PROMPT`](./rag-core-api/src/rag_core_api/prompt_templates/question_rephrasing_prompt.py) | The prompt used for rephrasing the question. The rephrased question (and the *original* question are both used for retrival of the documents)|
93+
| rephrasing_prompt | `str` | [`rag_core_api.prompt_templates.question_rephrasing_prompt.ANSWER_REPHRASING_PROMPT`](./rag-core-api/src/rag_core_api/prompt_templates/question_rephrasing_prompt.py) | The prompt used for rephrasing the question. The rephrased question (and the *original* question are both used for retrival of the documents) |
94+
| language_detection_prompt | `str` | [`rag_core_api.prompt_templates.language_detection_prompt.LANGUAGE_DETECTION_PROMPT`](./rag-core-api/src/rag_core_api/prompt_templates/language_detection_prompt.py) | Prompt for detecting input language. Enforces structured JSON output `{ "language": "<iso639-1>" }` and defaults to `en` when uncertain. |
9495
| langfuse_manager | [`rag_core_lib.impl.langfuse_manager.langfuse_manager.LangfuseManager`](./rag-core-lib/src/rag_core_lib/impl/langfuse_manager/langfuse_manager.py) | [`rag_core_lib.impl.langfuse_manager.langfuse_manager.LangfuseManager`](./rag-core-lib/src/rag_core_lib/impl/langfuse_manager/langfuse_manager.py) | Retrieves additional settings, as well as the prompt from langfuse if available. |
95-
| answer_generation_chain | [`rag_core_lib.chains.async_chain.AsyncChain[rag_core_api.impl.graph.graph_state.graph_state.AnswerGraphState, str]`](./rag-core-lib/src/rag_core_lib/chains/async_chain.py) | [`rag_core_api.impl.answer_generation_chains.answer_generation_chain.AnswerGenerationChain`](./rag-core-api/src/rag_core_api/impl/answer_generation_chains/answer_generation_chain.py) | LangChain chain used for answering the question. Is part of the *chat_graph*, |
96-
| rephrasing_chain | [`rag_core_lib.chains.async_chain.AsyncChain[rag_core_api.impl.graph.graph_state.graph_state.AnswerGraphState, str]`](./rag-core-lib/src/rag_core_lib/chains/async_chain.py) | [`rag_core_api.impl.answer_generation_chains.rephrasing_chain.RephrasingChain`](./rag-core-api/src/rag_core_api/impl/answer_generation_chains/rephrasing_chain.py) | LangChain chain used for rephrasing the question. Is part of the *chat_graph*. |
96+
| answer_generation_chain | [`rag_core_lib.chains.runnables.AsyncRunnable[rag_core_api.impl.graph.graph_state.graph_state.AnswerGraphState, str]`](./rag-core-lib/src/rag_core_lib/runnables/async_runnable.py) | [`rag_core_api.impl.answer_generation_chains.answer_generation_chain.AnswerGenerationChain`](./rag-core-api/src/rag_core_api/impl/answer_generation_chains/answer_generation_chain.py) | LangChain chain used for answering the question. Is part of the *chat_graph*, |
97+
| rephrasing_chain | [`rag_core_lib.chains.runnables.AsyncRunnable[rag_core_api.impl.graph.graph_state.graph_state.AnswerGraphState, str]`](./rag-core-lib/src/rag_core_lib/runnables/async_runnable.py) | [`rag_core_api.impl.answer_generation_chains.rephrasing_chain.RephrasingChain`](./rag-core-api/src/rag_core_api/impl/answer_generation_chains/rephrasing_chain.py) | LangChain chain used for rephrasing the question. Is part of the *chat_graph*. |
98+
| language_detection_chain | [`rag_core_lib.chains.runnables.AsyncRunnable[rag_core_api.impl.graph.graph_state.graph_state.AnswerGraphState, str]`](./rag-core-lib/src/rag_core_lib/runnables/async_runnable.py) | [`rag_core_api.impl.answer_generation_chains.language_detection_chain.LanguageDetectionChain`](./rag-core-api/src/rag_core_api/impl/answer_generation_chains/language_detection_chain.py) | Detects the language of the question and returns an ISO 639-1 code (e.g., `en`, `de`). Uses structured-output guidance and robust parsing with fallback to `en`. Part of the *chat_graph*. |
9799
| chat_graph | [`rag_core_api.graph.graph_base.GraphBase`](./rag-core-api/src/rag_core_api/graph/graph_base.py) | [`rag_core_api.impl.graph.chat_graph.DefaultChatGraph`](./rag-core-api/src/rag_core_api/impl/graph/chat_graph.py) | Langgraph graph that contains the entire logic for question answering. |
98-
| traced_chat_graph | [`rag_core_lib.chains.async_chain.AsyncChain[Any, Any]`](./rag-core-lib/src/rag_core_lib/chains/async_chain.py)| [`rag_core_lib.impl.tracers.langfuse_traced_chain.LangfuseTracedGraph`](./rag-core-lib/src/rag_core_lib/impl/tracers/langfuse_traced_chain.py) | Wraps around the *chat_graph* and add langfuse tracing. |
100+
| traced_chat_graph | [`rag_core_lib.chains.runnables.AsyncRunnable[Any, Any]`](./rag-core-lib/src/rag_core_lib/runnables/async_runnable.py)| [`rag_core_lib.impl.tracers.langfuse_traced_chain.LangfuseTracedGraph`](./rag-core-lib/src/rag_core_lib/impl/tracers/langfuse_traced_chain.py) | Wraps around the *chat_graph* and add langfuse tracing. |
99101
| evaluator | [`rag_core_api.impl.evaluator.langfuse_ragas_evaluator.LangfuseRagasEvaluator`](./rag-core-api/src/rag_core_api/impl/evaluator/langfuse_ragas_evaluator.py) | [`rag_core_api.impl.evaluator.langfuse_ragas_evaluator.LangfuseRagasEvaluator`](./rag-core-api/src/rag_core_api/impl/evaluator/langfuse_ragas_evaluator.py) | The evaulator used in the evaluate endpoint. |
100102
| chat_endpoint | [`rag_core_api.api_endpoints.chat.Chat`](./rag-core-api/src/rag_core_api/api_endpoints/chat.py) | [`rag_core_api.impl.api_endpoints.default_chat.DefaultChat`](./rag-core-api/src/rag_core_api/impl/api_endpoints/default_chat.py) | Implementation of the chat endpoint. Default implementation just calls the *traced_chat_graph* |
101103
| ragas_llm | `langchain_core.language_models.chat_models.BaseChatModel` | `langchain_openai.ChatOpenAI` or `langchain_ollama.ChatOllama` | The LLM used for the ragas evaluation. |
@@ -191,7 +193,7 @@ The extracted information will be summarized using LLM. The summary, as well as
191193
| langfuse_manager | [`rag_core_lib.impl.langfuse_manager.langfuse_manager.LangfuseManager`](./rag-core-lib/src/rag_core_lib/impl/langfuse_manager/langfuse_manager.py) | [`rag_core_lib.impl.langfuse_manager.langfuse_manager.LangfuseManager`](./rag-core-lib/src/rag_core_lib/impl/langfuse_manager/langfuse_manager.py) | Retrieves additional settings, as well as the prompt from langfuse if available. |
192194
| summarizer | [`admin_api_lib.summarizer.summarizer.Summarizer`](./admin-api-lib/src/admin_api_lib/summarizer/summarizer.py) | [`admin_api_lib.impl.summarizer.langchain_summarizer.LangchainSummarizer`](./admin-api-lib/src/admin_api_lib/impl/summarizer/langchain_summarizer.py) | Creates the summaries. Uses the shared retry decorator with optional per-summarizer overrides (see 2.4). |
193195
| untraced_information_enhancer |[`admin_api_lib.information_enhancer.information_enhancer.InformationEnhancer`](./admin-api-lib/src/admin_api_lib/information_enhancer/information_enhancer.py) | [`admin_api_lib.impl.information_enhancer.general_enhancer.GeneralEnhancer`](./admin-api-lib/src/admin_api_lib/impl/information_enhancer/general_enhancer.py) | Uses the *summarizer* to enhance the extracted documents. |
194-
| information_enhancer | [`rag_core_lib.chains.async_chain.AsyncChain[Any, Any]`](./rag-core-lib/src/rag_core_lib/chains/async_chain.py)| [`rag_core_lib.impl.tracers.langfuse_traced_chain.LangfuseTracedGraph`](./rag-core-lib/src/rag_core_lib/impl/tracers/langfuse_traced_chain.py) |Wraps around the *untraced_information_enhancer* and adds langfuse tracing. |
196+
| information_enhancer | [`rag_core_lib.chains.runnables.AsyncRunnable[Any, Any]`](./rag-core-lib/src/rag_core_lib/runnables/async_runnable.py)| [`rag_core_lib.impl.tracers.langfuse_traced_chain.LangfuseTracedGraph`](./rag-core-lib/src/rag_core_lib/impl/tracers/langfuse_traced_chain.py) |Wraps around the *untraced_information_enhancer* and adds langfuse tracing. |
195197
| document_deleter |[`admin_api_lib.api_endpoints.document_deleter.DocumentDeleter`](./admin-api-lib/src/admin_api_lib/api_endpoints/document_deleter.py) | [`admin_api_lib.impl.api_endpoints.default_document_deleter.DefaultDocumentDeleter`](./admin-api-lib/src/admin_api_lib/impl/api_endpoints/default_document_deleter.py) | Handles deletion of sources. |
196198
| documents_status_retriever | [`admin_api_lib.api_endpoints.documents_status_retriever.DocumentsStatusRetriever`](./admin-api-lib/src/admin_api_lib/api_endpoints/documents_status_retriever.py) | [`admin_api_lib.impl.api_endpoints.default_documents_status_retriever.DefaultDocumentsStatusRetriever`](./admin-api-lib/src/admin_api_lib/impl/api_endpoints/default_documents_status_retriever.py) |Handles return of source status. |
197199
| source_uploader | [`admin_api_lib.api_endpoints.source_uploader.SourceUploader`](./admin-api-lib/src/admin_api_lib/api_endpoints/source_uploader.py) | [`admin_api_lib.impl.api_endpoints.default_source_uploader.DefaultSourceUploader`](./admin-api-lib/src/admin_api_lib/impl/api_endpoints/default_source_uploader.py)| Handles data loading and extraction from various non-file sources. |

libs/rag-core-api/src/rag_core_api/dependency_container.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
AnswerGenerationChain,
2020
)
2121
from rag_core_api.impl.answer_generation_chains.rephrasing_chain import RephrasingChain
22+
from rag_core_api.impl.answer_generation_chains.language_detection_chain import LanguageDetectionChain
2223
from rag_core_api.impl.api_endpoints.default_chat import DefaultChat
2324
from rag_core_api.impl.api_endpoints.default_information_pieces_remover import (
2425
DefaultInformationPiecesRemover,
@@ -57,6 +58,7 @@
5758
from rag_core_api.prompt_templates.question_rephrasing_prompt import (
5859
QUESTION_REPHRASING_PROMPT,
5960
)
61+
from rag_core_api.prompt_templates.language_detection_prompt import LANGUAGE_DETECTION_PROMPT
6062
from rag_core_lib.impl.data_types.content_type import ContentType
6163
from rag_core_lib.impl.langfuse_manager.langfuse_manager import LangfuseManager
6264
from rag_core_lib.impl.llms.llm_factory import chat_model_provider
@@ -180,6 +182,7 @@ class DependencyContainer(DeclarativeContainer):
180182

181183
prompt = ANSWER_GENERATION_PROMPT
182184
rephrasing_prompt = QUESTION_REPHRASING_PROMPT
185+
language_detection_prompt = LANGUAGE_DETECTION_PROMPT
183186

184187
langfuse = Singleton(
185188
Langfuse,
@@ -194,6 +197,7 @@ class DependencyContainer(DeclarativeContainer):
194197
managed_prompts={
195198
AnswerGenerationChain.__name__: prompt,
196199
RephrasingChain.__name__: rephrasing_prompt,
200+
LanguageDetectionChain.__name__: language_detection_prompt,
197201
},
198202
llm=large_language_model,
199203
)
@@ -208,10 +212,16 @@ class DependencyContainer(DeclarativeContainer):
208212
langfuse_manager=langfuse_manager,
209213
)
210214

215+
language_detection_chain = Singleton(
216+
LanguageDetectionChain,
217+
langfuse_manager=langfuse_manager,
218+
)
219+
211220
chat_graph = Singleton(
212221
DefaultChatGraph,
213222
composed_retriever=composed_retriever,
214223
rephrasing_chain=rephrasing_chain,
224+
language_detection_chain=language_detection_chain,
215225
mapper=information_piece_mapper,
216226
answer_generation_chain=answer_generation_chain,
217227
error_messages=error_messages,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Language detection chain.
2+
3+
# Assumptions:
4+
# - We want a robust return of a 2-letter ISO 639-1 language code (lowercase),
5+
# defaulting to "en" when unsure or parsing fails.
6+
# - We instruct the LLM to produce structured JSON and include format instructions,
7+
# but we still parse defensively to handle non-conforming outputs.
8+
"""
9+
10+
import json
11+
import re
12+
from typing import Any, Optional
13+
14+
from langchain_core.output_parsers import StrOutputParser
15+
from langchain_core.runnables import Runnable, RunnableConfig
16+
17+
from rag_core_api.impl.graph.graph_state.graph_state import AnswerGraphState
18+
from rag_core_lib.runnables.async_runnable import AsyncRunnable
19+
from rag_core_lib.impl.langfuse_manager.langfuse_manager import LangfuseManager
20+
from pydantic import BaseModel, field_validator
21+
from langchain_core.output_parsers import PydanticOutputParser
22+
23+
24+
RunnableInput = AnswerGraphState
25+
RunnableOutput = str
26+
27+
28+
from rag_core_api.utils.utils import (
29+
strip_code_fences,
30+
norm_lang,
31+
extract_lang_from_parsed,
32+
JSONISH_DQ_RE,
33+
JSONISH_SQ_RE,
34+
JSONISH_LOOSE_RE,
35+
)
36+
37+
38+
class LanguageDetectionChain(AsyncRunnable[RunnableInput, RunnableOutput]):
39+
"""Base class for language detection of the input question."""
40+
41+
def __init__(self, langfuse_manager: LangfuseManager):
42+
"""Initialize LanguageDetectionChain with LangfuseManager."""
43+
self._langfuse_manager = langfuse_manager
44+
45+
async def ainvoke(
46+
self, chain_input: RunnableInput, config: Optional[RunnableConfig] = None, **kwargs: Any
47+
) -> RunnableOutput:
48+
"""
49+
Asynchronously detects the language and returns an ISO 639-1 code.
50+
51+
Returns
52+
-------
53+
RunnableOutput
54+
Two-letter ISO 639-1 language code in lowercase (e.g., "en", "de").
55+
"""
56+
raw = await self._create_chain().ainvoke(chain_input, config=config)
57+
return self._extract_language_code(raw)
58+
59+
def _create_chain(self) -> Runnable:
60+
# Provide format instructions to the prompt using a minimal Pydantic schema.
61+
class _LangSchema(BaseModel):
62+
language: str
63+
64+
@field_validator("language")
65+
@classmethod
66+
def two_letter_lower(cls, v: str) -> str:
67+
v = v.strip().lower()
68+
if not re.fullmatch(r"[a-z]{2}", v):
69+
raise ValueError("language must be a two-letter ISO 639-1 code")
70+
return v
71+
72+
# We don't use PydanticOutputParser directly in the chain to avoid hard failures
73+
# when the model doesn't follow instructions. Instead, we keep a string parser and
74+
# parse defensively in ainvoke(). We still inject format instructions to guide the LLM.
75+
try:
76+
fmt_instructions = PydanticOutputParser(
77+
pydantic_object=_LangSchema
78+
).get_format_instructions()
79+
except Exception:
80+
fmt_instructions = (
81+
"Return a strict JSON object with a single field 'language' as a "
82+
"lowercase two-letter ISO 639-1 code, e.g. {\"language\":\"de\"}."
83+
)
84+
85+
prompt = self._langfuse_manager.get_base_prompt(self.__class__.__name__).partial(
86+
format_instructions=fmt_instructions
87+
)
88+
89+
return prompt | self._langfuse_manager.get_base_llm(self.__class__.__name__) | StrOutputParser()
90+
91+
@staticmethod
92+
def _extract_language_code(raw_output: Any) -> str:
93+
"""Extract a two-letter ISO 639-1 language code; default to 'en' on failure."""
94+
95+
# 1) Already parsed structures
96+
c = extract_lang_from_parsed(raw_output)
97+
if c:
98+
return c
99+
100+
# 2) Strings (common case)
101+
if isinstance(raw_output, str):
102+
text = raw_output.strip().lstrip("\ufeff")
103+
text = strip_code_fences(text)
104+
105+
# a) Direct 'xx' or 'xx-YY'
106+
c = norm_lang(text)
107+
if c:
108+
return c
109+
110+
# b) Try strict JSON parsing
111+
try:
112+
data = json.loads(text)
113+
c = extract_lang_from_parsed(data)
114+
if c:
115+
return c
116+
except Exception:
117+
pass
118+
119+
# c) JSON-ish fallbacks (handles single quotes, loose key/value, etc.)
120+
for pattern in (JSONISH_DQ_RE, JSONISH_SQ_RE, JSONISH_LOOSE_RE):
121+
m = pattern.search(text)
122+
if m:
123+
c = norm_lang(m.group(1))
124+
if c:
125+
return c
126+
127+
# Fallback
128+
return "en"

libs/rag-core-api/src/rag_core_api/impl/graph/chat_graph.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
AnswerGenerationChain,
2121
)
2222
from rag_core_api.impl.answer_generation_chains.rephrasing_chain import RephrasingChain
23+
from rag_core_api.impl.answer_generation_chains.language_detection_chain import LanguageDetectionChain
2324
from rag_core_api.impl.graph.graph_state.graph_state import AnswerGraphState
2425
from rag_core_api.impl.retriever.no_or_empty_collection_error import (
2526
NoOrEmptyCollectionError,
@@ -71,6 +72,7 @@ def __init__(
7172
self,
7273
answer_generation_chain: AnswerGenerationChain,
7374
rephrasing_chain: RephrasingChain,
75+
language_detection_chain: LanguageDetectionChain,
7476
composed_retriever: Retriever,
7577
mapper: InformationPieceMapper,
7678
error_messages: ErrorMessages,
@@ -100,6 +102,7 @@ def __init__(
100102
self._mapper = mapper
101103
self._chat_history_settings = chat_history_settings
102104
self._rephrasing_chain = rephrasing_chain
105+
self._language_detection_chain = language_detection_chain
103106
self._error_messages = error_messages
104107
self._rephrase_node_builder = partial(self._rephrase_node)
105108
self._generate_node_builder = partial(self._generate_node)
@@ -200,7 +203,14 @@ def draw_graph(self, relative_dir_path: Optional[str] = None) -> None:
200203
#########
201204
async def _determine_language_node(self, state: dict, config: Optional[RunnableConfig] = None) -> dict:
202205
question = state["question"]
203-
question_language = langdetect.detect(question)
206+
# Prefer the LLM-based language detection; fallback to langdetect if needed inside the chain.
207+
try:
208+
question_language = await self._language_detection_chain.ainvoke(state, config=config)
209+
except Exception:
210+
try:
211+
question_language = langdetect.detect(question)
212+
except Exception:
213+
question_language = "en"
204214
logger.debug('Detected langauge for question "%s": %s', question, question_language)
205215
return {"language": question_language}
206216

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate
2+
3+
# Generic LangChain ChatPromptTemplate - works with any LLM
4+
LANGUAGE_DETECTION_PROMPT = ChatPromptTemplate.from_messages(
5+
[
6+
SystemMessagePromptTemplate.from_template(
7+
"""You are a helpful assistant that detects the language of the user's question.
8+
Return your answer as a strict JSON object with a single field 'language' containing the ISO 639-1 language code in lowercase.
9+
If you cannot determine the language with reasonable certainty, set 'language' to 'en'.
10+
11+
{format_instructions}
12+
13+
Examples (input -> output):
14+
- "What is the capital of Germany?" -> {{"language": "en"}}
15+
- "Was ist die Hauptstadt von Deutschland?" -> {{"language": "de"}}
16+
- "¿Cuál es la capital de Alemania?" -> {{"language": "es"}}
17+
- "Quelle est la capitale de l'Allemagne ?" -> {{"language": "fr"}}
18+
- "計算できません!!!" (ambiguous/gibberish) -> {{"language": "en"}}
19+
"""
20+
),
21+
HumanMessagePromptTemplate.from_template("""Question: {question}"""),
22+
]
23+
)

libs/rag-core-api/src/rag_core_api/utils/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)