e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
203 lines
7.2 KiB
Python
203 lines
7.2 KiB
Python
import asyncio
|
|
from collections import deque
|
|
from collections.abc import AsyncGenerator
|
|
import contextlib
|
|
import importlib
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
|
|
from deeptutor.logging import (
|
|
ProcessLogEvent,
|
|
bind_log_context,
|
|
capture_process_logs,
|
|
current_log_context,
|
|
)
|
|
|
|
|
|
def _format_sse(event: str, payload: dict[str, Any]) -> str:
|
|
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n"
|
|
|
|
|
|
class KnowledgeTaskStreamManager:
|
|
_instance: "KnowledgeTaskStreamManager | None" = None
|
|
_instance_lock = threading.Lock()
|
|
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self._buffers: dict[str, deque[dict[str, Any]]] = {}
|
|
self._subscribers: dict[str, list[tuple[asyncio.Queue, asyncio.AbstractEventLoop]]] = {}
|
|
|
|
@classmethod
|
|
def get_instance(cls) -> "KnowledgeTaskStreamManager":
|
|
if cls._instance is None:
|
|
with cls._instance_lock:
|
|
if cls._instance is None:
|
|
cls._instance = cls()
|
|
return cls._instance
|
|
|
|
def ensure_task(self, task_id: str):
|
|
with self._lock:
|
|
self._buffers.setdefault(task_id, deque(maxlen=500))
|
|
self._subscribers.setdefault(task_id, [])
|
|
|
|
def emit(self, task_id: str, event: str, payload: dict[str, Any]):
|
|
event_payload = {"event": event, "payload": payload}
|
|
with self._lock:
|
|
self._buffers.setdefault(task_id, deque(maxlen=500)).append(event_payload)
|
|
subscribers = list(self._subscribers.get(task_id, []))
|
|
|
|
for queue, loop in subscribers:
|
|
try:
|
|
loop.call_soon_threadsafe(self._queue_event, queue, event_payload)
|
|
except RuntimeError:
|
|
continue
|
|
|
|
def emit_process_log(self, task_id: str, event: ProcessLogEvent):
|
|
payload = event.to_dict()
|
|
payload.setdefault("context", {})["task_id"] = task_id
|
|
self.emit(task_id, "process_log", payload)
|
|
|
|
def emit_log(self, task_id: str, line: str):
|
|
event = ProcessLogEvent(
|
|
level="INFO",
|
|
message=line,
|
|
logger="deeptutor.knowledge.task",
|
|
timestamp=time.time(),
|
|
context={"task_id": task_id, "capability": "knowledge", "sink": "ui"},
|
|
)
|
|
self.emit_process_log(task_id, event)
|
|
|
|
def emit_complete(self, task_id: str, detail: str = "Task completed"):
|
|
self.emit(task_id, "complete", {"detail": detail, "task_id": task_id})
|
|
|
|
def emit_failed(self, task_id: str, detail: str, *, details: str | None = None):
|
|
payload: dict[str, Any] = {"detail": detail, "task_id": task_id}
|
|
if details:
|
|
payload["details"] = details
|
|
self.emit(task_id, "failed", payload)
|
|
|
|
def subscribe(
|
|
self, task_id: str
|
|
) -> tuple[asyncio.Queue[dict[str, Any]], list[dict[str, Any]], asyncio.AbstractEventLoop]:
|
|
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=200)
|
|
loop = asyncio.get_running_loop()
|
|
with self._lock:
|
|
self._buffers.setdefault(task_id, deque(maxlen=500))
|
|
self._subscribers.setdefault(task_id, []).append((queue, loop))
|
|
backlog = list(self._buffers[task_id])
|
|
return queue, backlog, loop
|
|
|
|
def unsubscribe(
|
|
self, task_id: str, queue: asyncio.Queue[dict[str, Any]], loop: asyncio.AbstractEventLoop
|
|
):
|
|
with self._lock:
|
|
subscribers = self._subscribers.get(task_id, [])
|
|
self._subscribers[task_id] = [
|
|
(subscriber_queue, subscriber_loop)
|
|
for subscriber_queue, subscriber_loop in subscribers
|
|
if subscriber_queue is not queue or subscriber_loop is not loop
|
|
]
|
|
|
|
async def stream(self, task_id: str) -> AsyncGenerator[str, None]:
|
|
queue, backlog, loop = self.subscribe(task_id)
|
|
try:
|
|
for item in backlog:
|
|
yield _format_sse(item["event"], item["payload"])
|
|
|
|
if backlog and backlog[-1]["event"] in {"complete", "failed"}:
|
|
return
|
|
|
|
while True:
|
|
item = await queue.get()
|
|
yield _format_sse(item["event"], item["payload"])
|
|
if item["event"] in {"complete", "failed"}:
|
|
break
|
|
finally:
|
|
self.unsubscribe(task_id, queue, loop)
|
|
|
|
@staticmethod
|
|
def _queue_event(queue: asyncio.Queue[dict[str, Any]], payload: dict[str, Any]):
|
|
try:
|
|
queue.put_nowait(payload)
|
|
except asyncio.QueueFull:
|
|
pass
|
|
|
|
|
|
class _TaskScopedLogHandler(logging.Handler):
|
|
"""Forward non-propagating library logs into one knowledge task stream."""
|
|
|
|
def __init__(self, task_id: str, manager: KnowledgeTaskStreamManager) -> None:
|
|
super().__init__(logging.INFO)
|
|
self._task_id = task_id
|
|
self._manager = manager
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
try:
|
|
context = current_log_context()
|
|
record_task_id = context.get("task_id")
|
|
if record_task_id and record_task_id != self._task_id:
|
|
return
|
|
|
|
context.setdefault("task_id", self._task_id)
|
|
context.setdefault("capability", "knowledge")
|
|
context.setdefault("sink", "ui")
|
|
self._manager.emit_process_log(
|
|
self._task_id,
|
|
ProcessLogEvent(
|
|
level=record.levelname,
|
|
message=record.getMessage(),
|
|
logger=record.name,
|
|
timestamp=record.created,
|
|
context=context,
|
|
),
|
|
)
|
|
except Exception:
|
|
self.handleError(record)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _capture_non_propagating_task_logs(task_id: str, manager: KnowledgeTaskStreamManager):
|
|
"""Capture library loggers that intentionally do not propagate to root."""
|
|
logger_names = ("lightrag", "graphrag", "graphrag_llm")
|
|
handlers: list[tuple[logging.Logger, _TaskScopedLogHandler]] = []
|
|
for logger_name in logger_names:
|
|
if logger_name == "lightrag":
|
|
with contextlib.suppress(Exception):
|
|
importlib.import_module("lightrag.utils")
|
|
source_logger = logging.getLogger(logger_name)
|
|
if source_logger.propagate:
|
|
continue
|
|
handler = _TaskScopedLogHandler(task_id, manager)
|
|
source_logger.addHandler(handler)
|
|
handlers.append((source_logger, handler))
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
for source_logger, handler in handlers:
|
|
if handler in source_logger.handlers:
|
|
source_logger.removeHandler(handler)
|
|
handler.close()
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def capture_task_logs(task_id: str):
|
|
"""Forward all logs bound to ``task_id`` into the task's SSE stream."""
|
|
manager = KnowledgeTaskStreamManager.get_instance()
|
|
manager.ensure_task(task_id)
|
|
|
|
def emit(event: ProcessLogEvent) -> None:
|
|
manager.emit_process_log(task_id, event)
|
|
|
|
with bind_log_context(task_id=task_id, capability="knowledge", sink="ui"):
|
|
with capture_process_logs(emit, task_id=task_id):
|
|
with _capture_non_propagating_task_logs(task_id, manager):
|
|
yield
|
|
|
|
|
|
def get_task_stream_manager() -> KnowledgeTaskStreamManager:
|
|
return KnowledgeTaskStreamManager.get_instance()
|