|
13 | 13 |
|
14 | 14 | from langchain_core.output_parsers import StrOutputParser |
15 | 15 | from langchain_core.runnables import Runnable, RunnableConfig |
| 16 | +from pydantic import BaseModel, field_validator |
| 17 | +from langchain_core.output_parsers import PydanticOutputParser |
16 | 18 |
|
17 | 19 | from rag_core_api.impl.graph.graph_state.graph_state import AnswerGraphState |
18 | 20 | from rag_core_lib.runnables.async_runnable import AsyncRunnable |
19 | 21 | 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 | 22 | from rag_core_api.utils.utils import ( |
29 | 23 | strip_code_fences, |
30 | 24 | norm_lang, |
|
35 | 29 | ) |
36 | 30 |
|
37 | 31 |
|
| 32 | +RunnableInput = AnswerGraphState |
| 33 | +RunnableOutput = str |
| 34 | + |
| 35 | + |
38 | 36 | class LanguageDetectionChain(AsyncRunnable[RunnableInput, RunnableOutput]): |
39 | 37 | """Base class for language detection of the input question.""" |
40 | 38 |
|
41 | 39 | def __init__(self, langfuse_manager: LangfuseManager): |
42 | 40 | """Initialize LanguageDetectionChain with LangfuseManager.""" |
43 | 41 | self._langfuse_manager = langfuse_manager |
44 | 42 |
|
| 43 | + @staticmethod |
| 44 | + def _strict_json_parse(text: str) -> Optional[str]: |
| 45 | + """Attempt to strictly parse JSON and extract the language code.""" |
| 46 | + try: |
| 47 | + data = json.loads(text) |
| 48 | + c = extract_lang_from_parsed(data) |
| 49 | + if c: |
| 50 | + return c |
| 51 | + except (json.JSONDecodeError, TypeError, ValueError): |
| 52 | + return None |
| 53 | + |
| 54 | + @staticmethod |
| 55 | + def _loose_json_parse(text: str) -> Optional[str]: |
| 56 | + for pattern in (JSONISH_DQ_RE, JSONISH_SQ_RE, JSONISH_LOOSE_RE): |
| 57 | + m = pattern.search(text) |
| 58 | + if m: |
| 59 | + c = norm_lang(m.group(1)) |
| 60 | + if c: |
| 61 | + return c |
| 62 | + return None |
| 63 | + |
| 64 | + @staticmethod |
| 65 | + def _extract_language_code(raw_output: Any) -> str: |
| 66 | + """Extract a two-letter ISO 639-1 language code; default to 'en' on failure.""" |
| 67 | + # 1) Already parsed structures |
| 68 | + c = extract_lang_from_parsed(raw_output) |
| 69 | + if c: |
| 70 | + return c |
| 71 | + |
| 72 | + # 2) Strings (common case) |
| 73 | + if isinstance(raw_output, str): |
| 74 | + text = raw_output.strip().lstrip("\ufeff") |
| 75 | + text = strip_code_fences(text) |
| 76 | + |
| 77 | + # a) Direct 'xx' or 'xx-YY' |
| 78 | + c = norm_lang(text) |
| 79 | + if c: |
| 80 | + return c |
| 81 | + |
| 82 | + # b) Try strict JSON parsing |
| 83 | + c = LanguageDetectionChain._strict_json_parse(text) |
| 84 | + if c: |
| 85 | + return c |
| 86 | + |
| 87 | + # c) JSON-ish fallbacks (handles single quotes, loose key/value, etc.) |
| 88 | + c = LanguageDetectionChain._loose_json_parse(text) |
| 89 | + if c: |
| 90 | + return c |
| 91 | + |
| 92 | + # Fallback |
| 93 | + return "en" |
| 94 | + |
45 | 95 | async def ainvoke( |
46 | 96 | self, chain_input: RunnableInput, config: Optional[RunnableConfig] = None, **kwargs: Any |
47 | 97 | ) -> RunnableOutput: |
@@ -73,56 +123,15 @@ def two_letter_lower(cls, v: str) -> str: |
73 | 123 | # when the model doesn't follow instructions. Instead, we keep a string parser and |
74 | 124 | # parse defensively in ainvoke(). We still inject format instructions to guide the LLM. |
75 | 125 | try: |
76 | | - fmt_instructions = PydanticOutputParser( |
77 | | - pydantic_object=_LangSchema |
78 | | - ).get_format_instructions() |
| 126 | + fmt_instructions = PydanticOutputParser(pydantic_object=_LangSchema).get_format_instructions() |
79 | 127 | except Exception: |
80 | 128 | fmt_instructions = ( |
81 | 129 | "Return a strict JSON object with a single field 'language' as a " |
82 | | - "lowercase two-letter ISO 639-1 code, e.g. {\"language\":\"de\"}." |
| 130 | + 'lowercase two-letter ISO 639-1 code, e.g. {"language":"de"}.' |
83 | 131 | ) |
84 | 132 |
|
85 | 133 | prompt = self._langfuse_manager.get_base_prompt(self.__class__.__name__).partial( |
86 | 134 | format_instructions=fmt_instructions |
87 | 135 | ) |
88 | 136 |
|
89 | 137 | 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" |
|
0 commit comments