454 lines
21 KiB
Python
454 lines
21 KiB
Python
"""Implement the Agentic RAG pipeline for multi-step reasoning (P0-1).
|
|||
|
|
|
||
|
|
Architecture
|
||
|
|
------------
|
||
|
|
The pipeline adds four explicit reasoning steps before answer generation:
|
||
|
|
|
||
|
|
1. Intent Analysis — classify query type (simple_qa / compare / multi_hop / ambiguous)
|
||
|
|
2. Query Planning — for complex intents, decompose into focused sub-queries
|
||
|
|
3. Iterative Retrieval — retrieve for each sub-query, merge with deduplication
|
||
|
|
4. Grounding Check — verify retrieved context is sufficient; refine query when not
|
||
|
|
5. Answer Generation — stream final answer with citations (reuses AnswerGenerator)
|
||
|
|
|
||
|
|
Each step emits SSE ``thinking`` events so the frontend can render the live
|
||
|
|
reasoning trace. The pipeline is entirely synchronous and returns a generator so
|
||
|
|
it plugs into the same ``iter_in_thread`` pattern used by the existing chat routes.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from typing import Generator
|
||
|
|
|
||
|
|
from loguru import logger
|
||
|
|
|
||
|
|
from app.application.knowledge import KnowledgeRetrievalService
|
||
|
|
from app.application.agent.hyde_expander import HyDEExpander
|
||
|
|
from app.config.settings import settings
|
||
|
|
from app.domain.conversation import ConversationStore
|
||
|
|
from app.domain.retrieval import RetrievedChunk
|
||
|
|
from app.infrastructure.llm.openai_compatible_answer_generator import OpenAICompatibleAnswerGenerator
|
||
|
|
from app.services.llm.llm_factory import get_llm_client
|
||
|
|
|
||
|
|
# ── Prompts ───────────────────────────────────────────────────────────────────
|
||
|
|
# Each prompt is kept module-level for easy review and fine-tuning.
|
||
|
|
|
||
|
|
_INTENT_SYSTEM = (
|
||
|
|
"You are a query classifier for a Chinese regulatory compliance knowledge base.\n\n"
|
||
|
|
"Classify the query into exactly one of:\n"
|
||
|
|
'- "simple_qa" : Single-hop, factual question about one regulation or clause\n'
|
||
|
|
'- "compare" : Comparison between two or more regulations, standards, or versions\n'
|
||
|
|
'- "multi_hop" : Requires chaining facts across multiple regulations to answer\n'
|
||
|
|
'- "ambiguous" : Too vague or broad to retrieve effectively\n\n'
|
||
|
|
"Return ONLY valid JSON — no markdown, no extra text:\n"
|
||
|
|
'{"type": "...", "reason": "one sentence", "requires_decomposition": true/false}\n\n'
|
||
|
|
'"requires_decomposition" must be true for compare and multi_hop types.'
|
||
|
|
)
|
||
|
|
|
||
|
|
_PLAN_SYSTEM = (
|
||
|
|
"You are a query planner for a Chinese regulatory compliance knowledge base.\n\n"
|
||
|
|
"Decompose the query into 2-4 focused, self-contained sub-queries that together fully "
|
||
|
|
"address the original question. Each sub-query must target one specific regulation, "
|
||
|
|
"clause, or concept and be independently searchable.\n\n"
|
||
|
|
"Return ONLY a valid JSON array — no markdown, no extra text:\n"
|
||
|
|
'["sub-query 1", "sub-query 2", ...]'
|
||
|
|
)
|
||
|
|
|
||
|
|
_GROUNDING_SYSTEM = (
|
||
|
|
"You are a grounding verifier for a regulatory compliance QA system.\n\n"
|
||
|
|
"Given a query and retrieved regulation passages, decide whether the passages contain "
|
||
|
|
"sufficient, accurate information to answer the query.\n\n"
|
||
|
|
"Return ONLY valid JSON — no markdown, no extra text:\n"
|
||
|
|
'{"sufficient": true/false, "confidence": 0.0-1.0, "reason": "one sentence", '
|
||
|
|
'"refined_query": "a more specific search query if not sufficient, else null"}'
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ── Result dataclasses ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class IntentResult:
|
||
|
|
"""Capture the output of the intent-analysis step."""
|
||
|
|
|
||
|
|
type: str = "simple_qa"
|
||
|
|
reason: str = ""
|
||
|
|
requires_decomposition: bool = False
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class GroundingResult:
|
||
|
|
"""Capture the output of the grounding-check step."""
|
||
|
|
|
||
|
|
sufficient: bool = True
|
||
|
|
confidence: float = 1.0
|
||
|
|
reason: str = ""
|
||
|
|
refined_query: str | None = None
|
||
|
|
|
||
|
|
|
||
|
|
# ── Service ───────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
class AgenticConversationService:
|
||
|
|
"""Multi-step Agentic RAG pipeline with live reasoning trace via SSE.
|
||
|
|
|
||
|
|
The service is intentionally synchronous so it can be wrapped in
|
||
|
|
``iter_in_thread`` by the route layer without any async boilerplate.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
retrieval_service: KnowledgeRetrievalService,
|
||
|
|
answer_generator: OpenAICompatibleAnswerGenerator,
|
||
|
|
conversation_store: ConversationStore,
|
||
|
|
) -> None:
|
||
|
|
"""Initialise with injected dependencies from the composition root."""
|
||
|
|
self.retrieval_service = retrieval_service
|
||
|
|
self.answer_generator = answer_generator
|
||
|
|
self.conversation_store = conversation_store
|
||
|
|
# HyDE expander is stateless — one instance shared for all requests.
|
||
|
|
self._hyde = HyDEExpander()
|
||
|
|
|
||
|
|
# ── Private helpers ───────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def _llm_json(
|
||
|
|
self,
|
||
|
|
system: str,
|
||
|
|
user: str,
|
||
|
|
provider: str | None,
|
||
|
|
model: str | None,
|
||
|
|
max_tokens: int = 300,
|
||
|
|
) -> dict | list | None:
|
||
|
|
"""Call the LLM with a JSON-only prompt and return the parsed result.
|
||
|
|
|
||
|
|
Returns ``None`` on any API or parse failure so callers can degrade
|
||
|
|
gracefully without raising.
|
||
|
|
"""
|
||
|
|
client = get_llm_client(
|
||
|
|
provider=provider or settings.llm_provider,
|
||
|
|
model=model or settings.llm_model,
|
||
|
|
)
|
||
|
|
resp = client.chat(
|
||
|
|
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||
|
|
max_tokens=max_tokens,
|
||
|
|
temperature=0.1,
|
||
|
|
)
|
||
|
|
if not resp.is_success:
|
||
|
|
logger.warning("AgenticService LLM call failed: {}", resp.error)
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
raw = resp.content.strip()
|
||
|
|
# Strip accidental markdown code fences the model may add.
|
||
|
|
if raw.startswith("```"):
|
||
|
|
parts = raw.split("```")
|
||
|
|
raw = parts[1] if len(parts) > 1 else raw
|
||
|
|
if raw.startswith("json"):
|
||
|
|
raw = raw[4:]
|
||
|
|
return json.loads(raw.strip())
|
||
|
|
except (json.JSONDecodeError, IndexError) as exc:
|
||
|
|
logger.debug("AgenticService JSON parse failed: {} | raw={}", exc, resp.content[:200])
|
||
|
|
return None
|
||
|
|
|
||
|
|
def _analyze_intent(
|
||
|
|
self, query: str, provider: str | None, model: str | None
|
||
|
|
) -> IntentResult:
|
||
|
|
"""Classify query intent to select the appropriate retrieval strategy."""
|
||
|
|
data = self._llm_json(
|
||
|
|
_INTENT_SYSTEM,
|
||
|
|
f"Query: {query}",
|
||
|
|
provider,
|
||
|
|
model,
|
||
|
|
max_tokens=settings.agentic_intent_max_tokens,
|
||
|
|
)
|
||
|
|
if isinstance(data, dict):
|
||
|
|
return IntentResult(
|
||
|
|
type=str(data.get("type", "simple_qa")),
|
||
|
|
reason=str(data.get("reason", "")),
|
||
|
|
requires_decomposition=bool(data.get("requires_decomposition", False)),
|
||
|
|
)
|
||
|
|
return IntentResult(type="simple_qa", reason="fallback — classifier returned no JSON", requires_decomposition=False)
|
||
|
|
|
||
|
|
def _plan_queries(
|
||
|
|
self, query: str, intent_type: str, provider: str | None, model: str | None
|
||
|
|
) -> list[str]:
|
||
|
|
"""Decompose a complex query into focused, independently-retrievable sub-queries."""
|
||
|
|
data = self._llm_json(
|
||
|
|
_PLAN_SYSTEM,
|
||
|
|
f"Original query ({intent_type}): {query}",
|
||
|
|
provider,
|
||
|
|
model,
|
||
|
|
max_tokens=settings.agentic_plan_max_tokens,
|
||
|
|
)
|
||
|
|
if isinstance(data, list) and data:
|
||
|
|
# Cap at configured maximum to keep latency predictable.
|
||
|
|
return [str(q) for q in data[:settings.agentic_max_sub_queries] if q]
|
||
|
|
return [query]
|
||
|
|
|
||
|
|
def _check_grounding(
|
||
|
|
self,
|
||
|
|
query: str,
|
||
|
|
chunks: list[RetrievedChunk],
|
||
|
|
provider: str | None,
|
||
|
|
model: str | None,
|
||
|
|
) -> GroundingResult:
|
||
|
|
"""Verify whether retrieved chunks are sufficient to ground an accurate answer.
|
||
|
|
|
||
|
|
Uses a fast score-threshold heuristic first; falls back to an LLM call only
|
||
|
|
when scores are borderline so that the happy-path adds no extra latency.
|
||
|
|
"""
|
||
|
|
if not chunks:
|
||
|
|
return GroundingResult(
|
||
|
|
sufficient=False,
|
||
|
|
confidence=0.0,
|
||
|
|
reason="未检索到相关内容",
|
||
|
|
refined_query=None,
|
||
|
|
)
|
||
|
|
|
||
|
|
avg_score = sum(c.score for c in chunks) / len(chunks)
|
||
|
|
# Fast path: high-confidence retrieval → skip extra LLM call.
|
||
|
|
if avg_score > settings.agentic_grounding_threshold and len(chunks) >= 3:
|
||
|
|
return GroundingResult(
|
||
|
|
sufficient=True,
|
||
|
|
confidence=round(avg_score, 3),
|
||
|
|
reason="检索置信度充足,无需二次查询",
|
||
|
|
refined_query=None,
|
||
|
|
)
|
||
|
|
|
||
|
|
# LLM-based grounding check for borderline retrievals.
|
||
|
|
context_preview = "\n".join(
|
||
|
|
f"[{i + 1}] (score={c.score:.2f}) {c.text[:200]}" for i, c in enumerate(chunks[:5])
|
||
|
|
)
|
||
|
|
data = self._llm_json(
|
||
|
|
_GROUNDING_SYSTEM,
|
||
|
|
f"Query: {query}\n\nRetrieved passages:\n{context_preview}",
|
||
|
|
provider,
|
||
|
|
model,
|
||
|
|
max_tokens=settings.agentic_grounding_max_tokens,
|
||
|
|
)
|
||
|
|
if isinstance(data, dict):
|
||
|
|
return GroundingResult(
|
||
|
|
sufficient=bool(data.get("sufficient", True)),
|
||
|
|
confidence=float(data.get("confidence", 0.5)),
|
||
|
|
reason=str(data.get("reason", "")),
|
||
|
|
refined_query=data.get("refined_query") or None,
|
||
|
|
)
|
||
|
|
return GroundingResult(sufficient=True, confidence=0.5, reason="grounding check skipped (parse error)", refined_query=None)
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _intent_to_template(intent_type: str) -> str:
|
||
|
|
"""Map an intent type to the best prompt template name for answer generation."""
|
||
|
|
mapping = {
|
||
|
|
"compare": "comparison",
|
||
|
|
"multi_hop": "compliance_qa",
|
||
|
|
"simple_qa": "compliance_qa",
|
||
|
|
"ambiguous": "compliance_qa",
|
||
|
|
}
|
||
|
|
return mapping.get(intent_type, "compliance_qa")
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _deduplicate(chunks: list[RetrievedChunk], max_chunks: int) -> list[RetrievedChunk]:
|
||
|
|
"""Remove duplicate chunk IDs, preserving first-occurrence order up to max_chunks."""
|
||
|
|
seen: set[str] = set()
|
||
|
|
result: list[RetrievedChunk] = []
|
||
|
|
for chunk in chunks:
|
||
|
|
if chunk.chunk_id not in seen:
|
||
|
|
seen.add(chunk.chunk_id)
|
||
|
|
result.append(chunk)
|
||
|
|
if len(result) >= max_chunks:
|
||
|
|
break
|
||
|
|
return result
|
||
|
|
|
||
|
|
# ── Public interface ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def stream_agentic_chat(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
query: str,
|
||
|
|
session_id: str | None = None,
|
||
|
|
filters: str | None = None,
|
||
|
|
provider: str | None = None,
|
||
|
|
model: str | None = None,
|
||
|
|
top_k: int = 5,
|
||
|
|
context_text: str | None = None,
|
||
|
|
context_filename: str | None = None,
|
||
|
|
) -> tuple[str, Generator[dict, None, None]]:
|
||
|
|
"""Run the full Agentic RAG pipeline and return ``(session_id, event_generator)``.
|
||
|
|
|
||
|
|
When context_text is provided (user-attached document) it is:
|
||
|
|
- Summarised and prepended to the intent-analysis prompt so the classifier
|
||
|
|
understands what kind of question is being asked.
|
||
|
|
- Treated as baseline grounding so the pipeline skips unnecessary retries
|
||
|
|
when the document itself is the primary source.
|
||
|
|
- Passed to the answer generator so the LLM sees the full document alongside
|
||
|
|
retrieved regulation chunks.
|
||
|
|
|
||
|
|
The generator yields SSE event dicts compatible with the route's
|
||
|
|
``iter_in_thread`` pattern.
|
||
|
|
"""
|
||
|
|
session = self.conversation_store.get_session(session_id) if session_id else None
|
||
|
|
if session is None:
|
||
|
|
session = self.conversation_store.create_session()
|
||
|
|
self.conversation_store.save_message(session.session_id, role="user", content=query)
|
||
|
|
history = [{"role": msg.role, "content": msg.content} for msg in session.messages[-10:]]
|
||
|
|
active_session_id = session.session_id
|
||
|
|
|
||
|
|
# Build a brief document summary for classifier/planner prompts (avoid
|
||
|
|
# passing the full text which could overwhelm small-context LLMs).
|
||
|
|
_doc_summary: str = ""
|
||
|
|
if context_text and context_text.strip():
|
||
|
|
_doc_label = context_filename or "document"
|
||
|
|
_preview = context_text.strip()[:400]
|
||
|
|
_doc_summary = f"[User has attached document: {_doc_label}]\nDocument preview: {_preview}…\n\n"
|
||
|
|
|
||
|
|
def event_stream() -> Generator[dict, None, None]:
|
||
|
|
"""Execute all pipeline steps and yield SSE events."""
|
||
|
|
# ── Step 1: Intent Analysis ──────────────────────────────────────
|
||
|
|
yield {"event": "thinking", "data": {"step": "intent_analysis", "status": "running"}}
|
||
|
|
# Prepend doc summary so the classifier knows what the user is asking about
|
||
|
|
intent_user_msg = f"{_doc_summary}Query: {query}" if _doc_summary else f"Query: {query}"
|
||
|
|
data = self._llm_json(
|
||
|
|
_INTENT_SYSTEM, intent_user_msg, provider, model,
|
||
|
|
max_tokens=settings.agentic_intent_max_tokens,
|
||
|
|
)
|
||
|
|
if isinstance(data, dict):
|
||
|
|
intent = IntentResult(
|
||
|
|
type=str(data.get("type", "simple_qa")),
|
||
|
|
reason=str(data.get("reason", "")),
|
||
|
|
requires_decomposition=bool(data.get("requires_decomposition", False)),
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
intent = IntentResult(type="simple_qa", reason="fallback", requires_decomposition=False)
|
||
|
|
logger.debug("Agentic intent: type={} decompose={}", intent.type, intent.requires_decomposition)
|
||
|
|
yield {
|
||
|
|
"event": "thinking",
|
||
|
|
"data": {
|
||
|
|
"step": "intent_analysis",
|
||
|
|
"status": "done",
|
||
|
|
"intent_type": intent.type,
|
||
|
|
"reason": intent.reason,
|
||
|
|
"requires_decomposition": intent.requires_decomposition,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
# ── Step 2: Query Planning ───────────────────────────────────────
|
||
|
|
sub_queries: list[str] = [query]
|
||
|
|
if intent.requires_decomposition:
|
||
|
|
yield {"event": "thinking", "data": {"step": "query_planning", "status": "running"}}
|
||
|
|
plan_user_msg = f"{_doc_summary}Original query ({intent.type}): {query}" if _doc_summary else f"Original query ({intent.type}): {query}"
|
||
|
|
data_plan = self._llm_json(
|
||
|
|
_PLAN_SYSTEM, plan_user_msg, provider, model,
|
||
|
|
max_tokens=settings.agentic_plan_max_tokens,
|
||
|
|
)
|
||
|
|
if isinstance(data_plan, list) and data_plan:
|
||
|
|
sub_queries = [str(q) for q in data_plan[:settings.agentic_max_sub_queries] if q]
|
||
|
|
logger.debug("Agentic sub-queries ({}): {}", len(sub_queries), sub_queries)
|
||
|
|
yield {
|
||
|
|
"event": "thinking",
|
||
|
|
"data": {"step": "query_planning", "status": "done", "sub_queries": sub_queries},
|
||
|
|
}
|
||
|
|
|
||
|
|
# ── Step 3: Iterative Retrieval ──────────────────────────────────
|
||
|
|
# Always retrieve using the user's original question (NOT the document
|
||
|
|
# text) so embedding quality is preserved for regulation matching.
|
||
|
|
# HyDE enriches the retrieval query with a short hypothetical answer
|
||
|
|
# to close the vocabulary gap between terse queries and long documents.
|
||
|
|
candidate_k = max(top_k * 3, 15)
|
||
|
|
all_chunks: list[RetrievedChunk] = []
|
||
|
|
|
||
|
|
# For simple_qa with a single query, HyDE gives the biggest benefit
|
||
|
|
# (bridging vague/colloquial questions to formal document language).
|
||
|
|
# For compare/multi_hop, the planner already decomposed into precise
|
||
|
|
# sub-queries, so HyDE is less critical but still applied per sub-query.
|
||
|
|
for idx, sq in enumerate(sub_queries, start=1):
|
||
|
|
yield {
|
||
|
|
"event": "thinking",
|
||
|
|
"data": {"step": "retrieving", "status": "running", "query": sq, "index": idx, "total": len(sub_queries)},
|
||
|
|
}
|
||
|
|
# HyDE expansion: generate hypothetical answer, embed it for retrieval.
|
||
|
|
# Falls back to original sub-query if LLM call fails.
|
||
|
|
retrieval_query = self._hyde.expand(sq)
|
||
|
|
chunks = self.retrieval_service.retrieve(query=retrieval_query, top_k=candidate_k, filters=filters)
|
||
|
|
all_chunks.extend(chunks)
|
||
|
|
yield {
|
||
|
|
"event": "thinking",
|
||
|
|
"data": {"step": "retrieving", "status": "done", "query": sq, "index": idx, "total": len(sub_queries), "found": len(chunks)},
|
||
|
|
}
|
||
|
|
|
||
|
|
unique_chunks = self._deduplicate(all_chunks, max_chunks=top_k * 4)
|
||
|
|
|
||
|
|
# ── Step 4: Grounding Check ──────────────────────────────────────
|
||
|
|
yield {"event": "thinking", "data": {"step": "grounding_check", "status": "running"}}
|
||
|
|
|
||
|
|
# When the user has attached a document, the document itself provides
|
||
|
|
# baseline grounding — skip the re-query loop to avoid the LLM asking
|
||
|
|
# "please provide the document text" as a refined query.
|
||
|
|
if context_text and context_text.strip():
|
||
|
|
grounding = GroundingResult(
|
||
|
|
sufficient=True,
|
||
|
|
confidence=0.95,
|
||
|
|
reason="用户已附件上传文档,以文档内容为基础作答",
|
||
|
|
refined_query=None,
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
grounding = self._check_grounding(query, unique_chunks, provider, model)
|
||
|
|
|
||
|
|
yield {
|
||
|
|
"event": "thinking",
|
||
|
|
"data": {
|
||
|
|
"step": "grounding_check",
|
||
|
|
"status": "done",
|
||
|
|
"sufficient": grounding.sufficient,
|
||
|
|
"confidence": grounding.confidence,
|
||
|
|
"reason": grounding.reason,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
# Only retry from vector store when no document is attached and grounding failed
|
||
|
|
if not grounding.sufficient and grounding.refined_query and not context_text:
|
||
|
|
logger.info("Grounding insufficient — re-querying: {}", grounding.refined_query)
|
||
|
|
yield {
|
||
|
|
"event": "thinking",
|
||
|
|
"data": {"step": "retrieving", "status": "running", "query": grounding.refined_query, "index": 1, "total": 1, "retry": True},
|
||
|
|
}
|
||
|
|
# Apply HyDE to the refined query as well for better retrieval.
|
||
|
|
refined_hyde_query = self._hyde.expand(grounding.refined_query)
|
||
|
|
refined_chunks = self.retrieval_service.retrieve(query=refined_hyde_query, top_k=candidate_k, filters=filters)
|
||
|
|
all_chunks.extend(refined_chunks)
|
||
|
|
unique_chunks = self._deduplicate(all_chunks, max_chunks=top_k * 4)
|
||
|
|
yield {
|
||
|
|
"event": "thinking",
|
||
|
|
"data": {"step": "retrieving", "status": "done", "query": grounding.refined_query, "index": 1, "total": 1, "found": len(refined_chunks), "retry": True},
|
||
|
|
}
|
||
|
|
|
||
|
|
final_chunks = unique_chunks[:top_k]
|
||
|
|
|
||
|
|
# ── Step 5: Answer Generation ────────────────────────────────────
|
||
|
|
sources_payload = [s.__dict__ for s in self.answer_generator._sources(final_chunks)]
|
||
|
|
yield {"event": "sources", "data": sources_payload}
|
||
|
|
|
||
|
|
answer_parts: list[str] = []
|
||
|
|
for event in self.answer_generator.stream_generate(
|
||
|
|
query=query,
|
||
|
|
retrieved_chunks=final_chunks,
|
||
|
|
history=history,
|
||
|
|
provider=provider,
|
||
|
|
model=model,
|
||
|
|
prompt_template=self._intent_to_template(intent.type),
|
||
|
|
context_text=context_text,
|
||
|
|
context_filename=context_filename,
|
||
|
|
):
|
||
|
|
if event.get("event") == "content":
|
||
|
|
answer_parts.append(str(event.get("data", "")))
|
||
|
|
yield event
|
||
|
|
|
||
|
|
full_answer = "".join(answer_parts)
|
||
|
|
self.conversation_store.save_message(
|
||
|
|
active_session_id,
|
||
|
|
role="assistant",
|
||
|
|
content=full_answer,
|
||
|
|
sources=sources_payload,
|
||
|
|
)
|
||
|
|
|
||
|
|
return active_session_id, event_stream()
|
||
|
|
|