File size: 5,672 Bytes
ed216da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
"""Streaming chat orchestration utilities for the frontend voicebot."""

from __future__ import annotations

import asyncio
import logging
import os
from queue import Queue
from threading import Lock, Thread
from typing import AsyncGenerator, Dict, Iterator, List, Optional

from dotenv import load_dotenv
from langfuse import Langfuse
from langfuse.decorators import langfuse_context, observe
import sys
sys.path.append(os.path.abspath('./backend'))
from models import LLMFinanceAnalyzer
from functions import MongoHybridSearch


load_dotenv(override=True)

logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)


langfuse = Langfuse(
    secret_key=os.getenv("LANGFUSE_SECRET_KEY"),
    public_key=os.getenv("LANGFUSE_PUBLIC_KEY"),
    host=os.getenv("LANGFUSE_HOST"),
)
langfuse_context.configure(environment="development")


try:
    llm_analyzer = LLMFinanceAnalyzer()
    search_engine = MongoHybridSearch()
    logger.info("Initialized LLM analyzer and Mongo hybrid search for streaming chat.")
except Exception as exc:
    logger.critical("Failed to initialise backend components: %s", exc, exc_info=True)
    raise


_stream_loop: Optional[asyncio.AbstractEventLoop] = None
_stream_thread: Optional[Thread] = None
_stream_loop_lock: "Lock" = Lock()


def _loop_worker(loop: asyncio.AbstractEventLoop) -> None:
    asyncio.set_event_loop(loop)
    loop.run_forever()


def _ensure_stream_loop() -> asyncio.AbstractEventLoop:
    global _stream_loop, _stream_thread

    with _stream_loop_lock:
        if _stream_loop is None or _stream_loop.is_closed():
            _stream_loop = asyncio.new_event_loop()
            _stream_thread = Thread(target=_loop_worker, args=(_stream_loop,), daemon=True)
            _stream_thread.start()

    return _stream_loop


def _create_truncated_history(
    full_conversation: List[Dict[str, str]],
    max_assistant_length: int,
) -> List[Dict[str, str]]:
    truncated = []
    for msg in full_conversation:
        processed = msg.copy()
        if processed.get("role") == "assistant" and len(processed.get("content", "")) > max_assistant_length:
            processed["content"] = processed["content"][:max_assistant_length] + "..."
        truncated.append(processed)
    return truncated


def _generate_pseudo_conversation(conversation: List[Dict[str, str]]) -> List[Dict[str, str]]:
    pseudo = "".join(f"{msg.get('role', 'unknown')}: {msg.get('content', '')}\n" for msg in conversation)
    return [{"role": "user", "content": pseudo.strip()}]


@observe()
async def _stream_chat_async(history: List[Dict[str, str]], message: str) -> AsyncGenerator[str, None]:
    full_conversation = [msg.copy() for msg in history] + [{"role": "user", "content": message}]
    truncated_history = _create_truncated_history(full_conversation, 300)
    pseudo_conversation = _generate_pseudo_conversation(truncated_history)

    rag_decision = "yes"
    logger.info("RAG decision: %s", rag_decision)

    if rag_decision == "yes":
        query = await llm_analyzer.generate_subquery(pseudo_conversation)
        if query is None:
            yield "ขออภัยค่ะ ไม่สามารถวิเคราะห์คำถามเพื่อดึงข้อมูลได้"
            return

        retrieved_data = ""
        if query:
            try:
                docs = await search_engine.search_documents(query)
                retrieved_data = "\n-------\n".join(docs)
                logger.info("Retrieved %d documents for streaming response.", len(docs))
            except Exception as search_err:
                logger.error("Error during document search: %s", search_err, exc_info=True)
                yield "ขออภัยค่ะ เกิดข้อผิดพลาดขณะค้นหาข้อมูล"
                return

        limited_conversation = full_conversation[-7:] if len(full_conversation) > 7 else full_conversation
        response_generator = llm_analyzer.generate_normal_response(retrieved_data, limited_conversation)

        async for chunk in response_generator:
            if chunk:
                yield chunk
                await asyncio.sleep(0.05)
    else:
        limited_conversation = full_conversation[-9:] if len(full_conversation) > 9 else full_conversation
        final_response = await llm_analyzer.generate_non_rag_response(limited_conversation)
        if final_response:
            yield final_response
        else:
            yield "ขออภัยค่ะ เกิดข้อผิดพลาดในการประมวลผลคำถามของคุณ"


def stream_chat_response(history: List[Dict[str, str]], message: str) -> Iterator[str]:
    """Synchronously iterate over streaming LLM chunks."""

    loop = _ensure_stream_loop()
    output_queue: "Queue[Optional[str]]" = Queue()

    async def runner() -> None:
        try:
            async for chunk in _stream_chat_async(history, message):
                output_queue.put_nowait(str(chunk))
        except Exception as exc:  # noqa: BLE001
            logger.error("Unhandled error in async chat stream: %s", exc, exc_info=True)
            output_queue.put_nowait(f"[Error: {exc}]")
        finally:
            output_queue.put_nowait(None)

    future = asyncio.run_coroutine_threadsafe(runner(), loop)

    while True:
        chunk = output_queue.get()
        if chunk is None:
            break
        yield chunk

    # Propagate any exception that was not handled in runner().
    future.result()


__all__ = ["stream_chat_response"]