Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

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()