feat(agent): SSE — progressive Themen-Tabs (Phase 2)
Der Compliance-Check streamt jetzt progressive Events; der Impressum-Tab
erscheint, sobald das Thema fertig ist, statt am Ende alles auf einmal.
Additiv — das Polling fürs finale Ergebnis bleibt.
- backend: _sse.py (Queue/emit/event_generator) + Endpoint
/compliance-check/{id}/stream; _update emittiert progress,
run_agent_outputs emittiert topic (laeuft jetzt frueh nach Phase B),
Orchestrator emittiert complete/error.
- frontend: SSE-Proxy-Route + EventSource in ComplianceCheckTab merged
topic-Events in agent_outputs -> Tab erscheint progressiv.
- Tests: backend 5 passed (SSE + agent_outputs); tsc 0 neue Fehler,
vitest 2 passed, check-loc 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Compliance-Check SSE-Proxy
|
||||||
|
* GET /api/sdk/v1/agent/compliance-check/{check_id}/stream
|
||||||
|
* → backend /api/compliance/agent/compliance-check/{check_id}/stream
|
||||||
|
*
|
||||||
|
* Reicht den text/event-stream-Body unmodifiziert durch (progressive
|
||||||
|
* topic-/progress-Events fürs Frontend). Additiv zum Polling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL =
|
||||||
|
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
|
||||||
|
'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ check_id: string }> },
|
||||||
|
) {
|
||||||
|
const { check_id } = await params
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/agent/compliance-check/${check_id}/stream`,
|
||||||
|
{ signal: AbortSignal.timeout(1_800_000) }, // 30 min
|
||||||
|
)
|
||||||
|
return new NextResponse(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSE-Stream zum Backend fehlgeschlagen' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback, useRef } from 'react'
|
||||||
import { ComplianceResultTabs } from './ComplianceResultTabs'
|
import { ComplianceResultTabs } from './ComplianceResultTabs'
|
||||||
import { DocumentRow } from './DocumentRow'
|
import { DocumentRow } from './DocumentRow'
|
||||||
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
import { PreScanWizard, useScanContext, isContextComplete } from './PreScanWizard'
|
||||||
@@ -35,6 +35,9 @@ export function ComplianceCheckTab() {
|
|||||||
if (typeof window === 'undefined') return []
|
if (typeof window === 'undefined') return []
|
||||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] }
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] }
|
||||||
})
|
})
|
||||||
|
// SSE: progressive Themen-Tabs (additiv zum Polling).
|
||||||
|
const esRef = useRef<EventSource | null>(null)
|
||||||
|
React.useEffect(() => () => { try { esRef.current?.close() } catch { /* noop */ } }, [])
|
||||||
|
|
||||||
// Persist URLs and texts (not loading/error state)
|
// Persist URLs and texts (not loading/error state)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -117,6 +120,38 @@ export function ComplianceCheckTab() {
|
|||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
}, [updateDoc])
|
}, [updateDoc])
|
||||||
|
|
||||||
|
// SSE: füllt agent_outputs progressiv, sobald ein Thema fertig ist.
|
||||||
|
// Das Polling unten liefert weiterhin das finale Gesamtergebnis.
|
||||||
|
const openTopicStream = useCallback((checkId: string) => {
|
||||||
|
try { esRef.current?.close() } catch { /* noop */ }
|
||||||
|
const partial: any = { results: [], agent_outputs: {} }
|
||||||
|
const es = new EventSource(
|
||||||
|
`/api/sdk/v1/agent/compliance-check/${checkId}/stream`,
|
||||||
|
)
|
||||||
|
esRef.current = es
|
||||||
|
es.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(ev.data)
|
||||||
|
if (data.type === 'topic' && data.topic && data.output) {
|
||||||
|
partial.agent_outputs = {
|
||||||
|
...partial.agent_outputs, [data.topic]: data.output,
|
||||||
|
}
|
||||||
|
setResults((prev: any) =>
|
||||||
|
(prev && Array.isArray(prev.results) && prev.results.length > 0)
|
||||||
|
? prev // finales Ergebnis schon da → behalten
|
||||||
|
: { ...partial },
|
||||||
|
)
|
||||||
|
} else if (data.type === 'progress') {
|
||||||
|
if (data.msg) setProgress(data.msg)
|
||||||
|
if (typeof data.pct === 'number') setProgressPct(data.pct)
|
||||||
|
} else if (data.type === 'complete' || data.type === 'stream_close') {
|
||||||
|
try { es.close() } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
es.onerror = () => { try { es.close() } catch { /* noop */ } }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length
|
const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -157,6 +192,7 @@ export function ComplianceCheckTab() {
|
|||||||
if (!check_id) throw new Error('Keine Check-ID erhalten')
|
if (!check_id) throw new Error('Keine Check-ID erhalten')
|
||||||
setActiveCheckId(check_id)
|
setActiveCheckId(check_id)
|
||||||
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
|
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
|
||||||
|
openTopicStream(check_id)
|
||||||
|
|
||||||
// Poll for results (max 25 min = 500 polls x 3s)
|
// Poll for results (max 25 min = 500 polls x 3s)
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
@@ -201,6 +237,7 @@ export function ComplianceCheckTab() {
|
|||||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
setProgress('')
|
setProgress('')
|
||||||
setProgressPct(0)
|
setProgressPct(0)
|
||||||
|
try { esRef.current?.close() } catch { /* noop */ }
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import logging
|
|||||||
|
|
||||||
from compliance.services.specialist_agents import REGISTRY, AgentInput
|
from compliance.services.specialist_agents import REGISTRY, AgentInput
|
||||||
|
|
||||||
|
from ._sse import emit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# topic key (matches state["doc_texts"]) -> registered agent_id
|
# topic key (matches state["doc_texts"]) -> registered agent_id
|
||||||
@@ -43,7 +45,9 @@ def _derive_scope(profile_dict: dict) -> list[str]:
|
|||||||
|
|
||||||
async def run_agent_outputs(state: dict) -> None:
|
async def run_agent_outputs(state: dict) -> None:
|
||||||
"""Für jedes Topic mit registriertem v3-Agent + ausreichend Text:
|
"""Für jedes Topic mit registriertem v3-Agent + ausreichend Text:
|
||||||
Agent laufen lassen und den strukturierten AgentOutput ablegen."""
|
Agent laufen lassen, AgentOutput ablegen + als SSE topic-Event
|
||||||
|
emittieren (Tab füllt sich progressiv)."""
|
||||||
|
check_id = state.get("check_id", "")
|
||||||
doc_texts = state.get("doc_texts") or {}
|
doc_texts = state.get("doc_texts") or {}
|
||||||
profile_dict = state.get("profile_dict") or {}
|
profile_dict = state.get("profile_dict") or {}
|
||||||
req = state.get("req")
|
req = state.get("req")
|
||||||
@@ -75,6 +79,8 @@ async def run_agent_outputs(state: dict) -> None:
|
|||||||
origin_domain=origin_domain,
|
origin_domain=origin_domain,
|
||||||
))
|
))
|
||||||
outputs[topic] = out.model_dump(mode="json")
|
outputs[topic] = out.model_dump(mode="json")
|
||||||
|
emit(check_id, {"type": "topic", "topic": topic,
|
||||||
|
"output": outputs[topic]})
|
||||||
logger.info(
|
logger.info(
|
||||||
"agent_outputs[%s]: %d findings, confidence %.2f",
|
"agent_outputs[%s]: %d findings, confidence %.2f",
|
||||||
topic, len(out.findings), out.confidence,
|
topic, len(out.findings), out.confidence,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ._constants import (
|
|||||||
_DOC_TYPE_LABELS,
|
_DOC_TYPE_LABELS,
|
||||||
_compliance_check_jobs,
|
_compliance_check_jobs,
|
||||||
)
|
)
|
||||||
|
from ._sse import emit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ def _update(check_id: str, msg: str, pct: int | None = None) -> None:
|
|||||||
job["progress"] = msg
|
job["progress"] = msg
|
||||||
if pct is not None:
|
if pct is not None:
|
||||||
job["progress_pct"] = max(0, min(100, int(pct)))
|
job["progress_pct"] = max(0, min(100, int(pct)))
|
||||||
|
emit(check_id, {"type": "progress", "msg": msg,
|
||||||
|
"pct": job.get("progress_pct", 0)})
|
||||||
|
|
||||||
|
|
||||||
def _doc_type_label(doc_type: str) -> str:
|
def _doc_type_label(doc_type: str) -> str:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from ._b19_wiring import run_b19
|
|||||||
from ._b20_wiring import run_b20
|
from ._b20_wiring import run_b20
|
||||||
from ._b22_wiring import run_b22
|
from ._b22_wiring import run_b22
|
||||||
from ._constants import _compliance_check_jobs
|
from ._constants import _compliance_check_jobs
|
||||||
|
from ._sse import emit
|
||||||
from ._phase_a_resolve import run_phase_a
|
from ._phase_a_resolve import run_phase_a
|
||||||
from ._phase_b_profile_check import run_phase_b
|
from ._phase_b_profile_check import run_phase_b
|
||||||
from ._phase_c_banner import run_phase_c
|
from ._phase_c_banner import run_phase_c
|
||||||
@@ -71,6 +72,10 @@ async def run_compliance_check(check_id: str, req) -> None:
|
|||||||
logger.warning("chatbot-policy enrichment skipped: %s", e)
|
logger.warning("chatbot-policy enrichment skipped: %s", e)
|
||||||
# Phase B: Step 2 (profile detect) + Step 3 (per-doc checks)
|
# Phase B: Step 2 (profile detect) + Step 3 (per-doc checks)
|
||||||
await run_phase_b(state)
|
await run_phase_b(state)
|
||||||
|
# Strukturierter v3-AgentOutput pro Thema — früh (Impressum-Text +
|
||||||
|
# Profil liegen vor) → SSE topic-Event, Tab erscheint progressiv,
|
||||||
|
# während Banner/Vendor/B-Wirings noch laufen. Additiv zu B18.
|
||||||
|
await run_agent_outputs(state)
|
||||||
# Phase C: Step 3b-d (banner + cross-check + TCF) + Step 4
|
# Phase C: Step 3b-d (banner + cross-check + TCF) + Step 4
|
||||||
await run_phase_c(state)
|
await run_phase_c(state)
|
||||||
# Phase C-2: optional browser-matrix scan (env BROWSER_MATRIX=true)
|
# Phase C-2: optional browser-matrix scan (env BROWSER_MATRIX=true)
|
||||||
@@ -96,9 +101,6 @@ async def run_compliance_check(check_id: str, req) -> None:
|
|||||||
run_b16(state) # Footer-Label-vs-URL-Slug-Drift
|
run_b16(state) # Footer-Label-vs-URL-Slug-Drift
|
||||||
await run_b17(state) # Audit-Walk-Video (Beweis-Aufzeichnung)
|
await run_b17(state) # Audit-Walk-Video (Beweis-Aufzeichnung)
|
||||||
await run_b18(state) # Impressum-Specialist-Agent (Pattern+LLM)
|
await run_b18(state) # Impressum-Specialist-Agent (Pattern+LLM)
|
||||||
# Strukturierter v3-AgentOutput pro Thema → standardisierte
|
|
||||||
# Ergebnis-Tabs im Frontend (additiv zu B18-HTML).
|
|
||||||
await run_agent_outputs(state)
|
|
||||||
run_b19(state) # Cookie-Coherence (Salesforce-as-essential)
|
run_b19(state) # Cookie-Coherence (Salesforce-as-essential)
|
||||||
await run_b20(state) # Legacy-URL-Discovery (Sitemap+Wayback)
|
await run_b20(state) # Legacy-URL-Discovery (Sitemap+Wayback)
|
||||||
run_b22(state) # Cross-Domain-Legal-Doc-Hosting (Elli/LogPay)
|
run_b22(state) # Cross-Domain-Legal-Doc-Hosting (Elli/LogPay)
|
||||||
@@ -110,8 +112,10 @@ async def run_compliance_check(check_id: str, req) -> None:
|
|||||||
run_phase_e(state)
|
run_phase_e(state)
|
||||||
# Phase F: Step 7 persist + audit log + unified findings
|
# Phase F: Step 7 persist + audit log + unified findings
|
||||||
run_phase_f(state)
|
run_phase_f(state)
|
||||||
|
emit(check_id, {"type": "complete", "status": "completed"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Compliance check %s failed: %s",
|
logger.error("Compliance check %s failed: %s",
|
||||||
check_id, e, exc_info=True)
|
check_id, e, exc_info=True)
|
||||||
_compliance_check_jobs[check_id]["status"] = "failed"
|
_compliance_check_jobs[check_id]["status"] = "failed"
|
||||||
_compliance_check_jobs[check_id]["error"] = str(e)[:500]
|
_compliance_check_jobs[check_id]["error"] = str(e)[:500]
|
||||||
|
emit(check_id, {"type": "error", "error": str(e)[:300]})
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""SSE-Plumbing für den Compliance-Check — pro check_id eine Event-Queue
|
||||||
|
+ Generator. Spiegelt das Agent-Test-SSE (specialist_agent_routes).
|
||||||
|
|
||||||
|
ADDITIV: Das Polling auf GET /compliance-check/{check_id} bleibt die
|
||||||
|
Wahrheit fürs finale Ergebnis. SSE liefert nur **progressive** Events,
|
||||||
|
damit sich die Themen-Tabs füllen, sobald ein Thema fertig ist:
|
||||||
|
- {type:"progress", msg, pct} (aus _update)
|
||||||
|
- {type:"topic", topic, output} (aus run_agent_outputs, pro Thema)
|
||||||
|
- {type:"complete", status} (Orchestrator-Ende)
|
||||||
|
Geht ein Event verloren (Queue voll / kein Client) ist das unkritisch —
|
||||||
|
der Tab kommt spätestens mit dem finalen Poll.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from ._constants import _compliance_check_jobs
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# In-memory Event-Queues pro check_id. Restart-fragil, aber für einen
|
||||||
|
# Live-Stream ausreichend (Polling ist der persistente Pfad).
|
||||||
|
_check_queues: dict[str, "asyncio.Queue[dict]"] = {}
|
||||||
|
|
||||||
|
_TERMINAL_JOB_STATES = ("completed", "failed", "skipped_tdm")
|
||||||
|
|
||||||
|
|
||||||
|
def new_queue(check_id: str) -> None:
|
||||||
|
"""Legt die Event-Queue für einen Check an (in POST /compliance-check)."""
|
||||||
|
_check_queues[check_id] = asyncio.Queue(maxsize=500)
|
||||||
|
|
||||||
|
|
||||||
|
def emit(check_id: str, event: dict) -> None:
|
||||||
|
"""Non-blocking best-effort push. Synchron, damit auch das synchrone
|
||||||
|
_update() emittieren kann."""
|
||||||
|
q = _check_queues.get(check_id)
|
||||||
|
if q is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
q.put_nowait(event)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
pass # Client zu langsam — Poll holt den Stand nach
|
||||||
|
|
||||||
|
|
||||||
|
def _format_sse(payload: dict) -> str:
|
||||||
|
return f"data: {json.dumps(payload, default=str)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def event_generator(check_id: str) -> AsyncGenerator[str, None]:
|
||||||
|
"""Draint die Queue bis der Check terminal ist. Heartbeat alle 25s."""
|
||||||
|
q = _check_queues.get(check_id)
|
||||||
|
if q is None:
|
||||||
|
# Check evtl. schon fertig (Queue aufgeräumt) → Client soll pollen.
|
||||||
|
yield _format_sse({"type": "stream_close", "reason": "no_queue"})
|
||||||
|
return
|
||||||
|
yield _format_sse({"type": "hello", "check_id": check_id})
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = await asyncio.wait_for(q.get(), timeout=25.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield _format_sse({"type": "heartbeat"})
|
||||||
|
job = _compliance_check_jobs.get(check_id) or {}
|
||||||
|
if job.get("status") in _TERMINAL_JOB_STATES:
|
||||||
|
yield _format_sse({"type": "complete",
|
||||||
|
"status": job.get("status")})
|
||||||
|
yield _format_sse({"type": "stream_close"})
|
||||||
|
return
|
||||||
|
continue
|
||||||
|
yield _format_sse(event)
|
||||||
|
if event.get("type") in ("complete", "error"):
|
||||||
|
yield _format_sse({"type": "stream_close"})
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
# Queue erst nach 5 Min freigeben (späte Reconnects).
|
||||||
|
asyncio.get_event_loop().call_later(
|
||||||
|
300, lambda: _check_queues.pop(check_id, None),
|
||||||
|
)
|
||||||
@@ -30,6 +30,7 @@ import uuid as _uuid
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
# ── Re-exports: external callers import these from THIS module ──────
|
# ── Re-exports: external callers import these from THIS module ──────
|
||||||
from .agent_check._constants import ( # noqa: F401
|
from .agent_check._constants import ( # noqa: F401
|
||||||
@@ -63,6 +64,7 @@ from .agent_check._schemas import (
|
|||||||
ExtractTextRequest,
|
ExtractTextRequest,
|
||||||
)
|
)
|
||||||
from .agent_check._single_check import _check_single # noqa: F401
|
from .agent_check._single_check import _check_single # noqa: F401
|
||||||
|
from .agent_check._sse import event_generator, new_queue
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -137,6 +139,7 @@ async def start_compliance_check(req: ComplianceCheckRequest):
|
|||||||
"result": None,
|
"result": None,
|
||||||
"error": "",
|
"error": "",
|
||||||
}
|
}
|
||||||
|
new_queue(check_id) # SSE: progressive topic-Events fürs Frontend
|
||||||
asyncio.create_task(_run_compliance_check(check_id, req))
|
asyncio.create_task(_run_compliance_check(check_id, req))
|
||||||
return ComplianceCheckStartResponse(check_id=check_id, status="running")
|
return ComplianceCheckStartResponse(check_id=check_id, status="running")
|
||||||
|
|
||||||
@@ -157,6 +160,21 @@ async def get_compliance_check_status(check_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compliance-check/{check_id}/stream")
|
||||||
|
async def stream_compliance_check(check_id: str) -> StreamingResponse:
|
||||||
|
"""SSE-Stream: progressive Events (progress/topic/complete) eines
|
||||||
|
laufenden Checks. Additiv zum Polling auf /compliance-check/{check_id}."""
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(check_id),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no", # nginx: nicht puffern
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── P80: Snapshot + Replay ───────────────────────────────────────────
|
# ── P80: Snapshot + Replay ───────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/snapshots")
|
@router.get("/snapshots")
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Phase 2: SSE-Plumbing für den Compliance-Check.
|
||||||
|
|
||||||
|
Deckt emit (Queue-Push), _format_sse (SSE-Zeilenformat) und den
|
||||||
|
event_generator (hello → Events → stream_close bei 'complete') ab.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from compliance.api.agent_check import _sse
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_pushes_and_format():
|
||||||
|
cid = "sse-test-1"
|
||||||
|
_sse.new_queue(cid)
|
||||||
|
_sse.emit(cid, {"type": "topic", "topic": "impressum", "output": {"x": 1}})
|
||||||
|
q = _sse._check_queues[cid]
|
||||||
|
assert q.qsize() == 1
|
||||||
|
ev = q.get_nowait()
|
||||||
|
assert ev["type"] == "topic" and ev["topic"] == "impressum"
|
||||||
|
line = _sse._format_sse(ev)
|
||||||
|
assert line.startswith("data: ") and line.endswith("\n\n")
|
||||||
|
assert '"impressum"' in line
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_is_noop_without_queue():
|
||||||
|
# Kein new_queue → emit darf nicht crashen (best-effort).
|
||||||
|
_sse.emit("does-not-exist-xyz", {"type": "topic"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_generator_streams_topic_then_closes_on_complete():
|
||||||
|
cid = "sse-test-gen"
|
||||||
|
_sse.new_queue(cid)
|
||||||
|
_sse.emit(cid, {"type": "topic", "topic": "impressum", "output": {}})
|
||||||
|
_sse.emit(cid, {"type": "complete", "status": "completed"})
|
||||||
|
|
||||||
|
async def collect():
|
||||||
|
out = []
|
||||||
|
async for line in _sse.event_generator(cid):
|
||||||
|
out.append(line)
|
||||||
|
if len(out) > 12: # safety
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
blob = "".join(asyncio.run(collect()))
|
||||||
|
assert '"type": "hello"' in blob
|
||||||
|
assert '"topic": "impressum"' in blob
|
||||||
|
assert '"type": "complete"' in blob
|
||||||
|
assert '"type": "stream_close"' in blob
|
||||||
Reference in New Issue
Block a user