diff --git a/backend/agent_summary/agent/summary_agent.py b/backend/agent_summary/agent/summary_agent.py index 2044d9d..29fd981 100644 --- a/backend/agent_summary/agent/summary_agent.py +++ b/backend/agent_summary/agent/summary_agent.py @@ -1,6 +1,7 @@ """SummaryAgent 核心实现""" import re +from collections.abc import AsyncIterator from time import time from urllib.parse import urlparse @@ -178,6 +179,171 @@ async def summarize(self, entry_id: str, content: str) -> dict: "usage": usage, } + async def stream_summarize(self, entry_id: str, content: str) -> AsyncIterator[dict]: + """流式摘要:按段推送 chunk 事件,最后推送 complete。""" + from ..analysis.chunker import chunk_by_headings, chunk_by_paragraphs + from ..analysis.prompts import SUMMARY_PROMPTS + from ..analysis.strategies import select_strategy + from ..core.config import CHUNK_MAX_CHARS + + state = AgentState(entry_id=entry_id, content=content) + + await self.hooks.emit("before_analyze", state) + state = await AnalyzeStep().execute(state, self) + await self.hooks.emit("after_analyze", state) + + next_step = await self.router.route(state) + state.step_history.append(f"route:{next_step}") + + if next_step == "search": + state = await SearchStep().execute(state, self) + + strategy = select_strategy(state.profile) if state.profile else "direct" + + yield { + "type": "start", + "entry_id": entry_id, + "provider": self.provider_name, + "model": self.model_name, + "strategy": strategy, + } + + chunk_index = 0 + accumulated = "" + + if strategy == "hierarchical": + chunks = chunk_by_headings(state.content, CHUNK_MAX_CHARS) + chunk_summaries: list[str] = [] + for chunk in chunks: + prompt = SUMMARY_PROMPTS["hierarchical"].format(content=chunk) + summary = await self.llm.chat(prompt) + chunk_summaries.append(summary) + accumulated = "\n\n".join(chunk_summaries) + yield { + "type": "chunk", + "chunk_index": chunk_index, + "delta_text": summary, + "summary_text": accumulated, + "phase": "chunk", + "failed": False, + } + chunk_index += 1 + + merge_prompt = SUMMARY_PROMPTS["merge"].format( + chunk_summaries="\n".join(chunk_summaries) + ) + merge_text = "" + async for token in self._stream_llm_text(merge_prompt): + merge_text += token + yield { + "type": "chunk", + "chunk_index": chunk_index, + "delta_text": token, + "summary_text": merge_text, + "phase": "merge", + "failed": False, + } + state.summary = merge_text + chunk_index += 1 + + elif strategy == "single_pass": + chunks = chunk_by_paragraphs(state.content, CHUNK_MAX_CHARS) + chunk_summaries = [] + for chunk in chunks: + prompt = SUMMARY_PROMPTS["hierarchical"].format(content=chunk) + summary = await self.llm.chat(prompt) + chunk_summaries.append(summary) + accumulated = "\n\n".join(chunk_summaries) + yield { + "type": "chunk", + "chunk_index": chunk_index, + "delta_text": summary, + "summary_text": accumulated, + "phase": "chunk", + "failed": False, + } + chunk_index += 1 + + merge_prompt = SUMMARY_PROMPTS["merge"].format( + chunk_summaries="\n".join(chunk_summaries) + ) + merge_text = "" + async for token in self._stream_llm_text(merge_prompt): + merge_text += token + yield { + "type": "chunk", + "chunk_index": chunk_index, + "delta_text": token, + "summary_text": merge_text, + "phase": "merge", + "failed": False, + } + state.summary = merge_text + chunk_index += 1 + + else: + prompt = SUMMARY_PROMPTS["direct"].format(content=state.content) + direct_text = "" + async for token in self._stream_llm_text(prompt): + direct_text += token + yield { + "type": "chunk", + "chunk_index": chunk_index, + "delta_text": token, + "summary_text": direct_text, + "phase": "direct", + "failed": False, + } + state.summary = direct_text + chunk_index += 1 + + state.step_history.append(f"summarize:{strategy}") + + before_eval = state.summary + state = await EvaluateStep().execute(state, self) + if state.summary != before_eval and "evaluate:retry" in state.step_history: + yield { + "type": "chunk", + "chunk_index": chunk_index, + "delta_text": state.summary or "", + "summary_text": state.summary or "", + "phase": "evaluate", + "failed": False, + } + + usage = {} + if hasattr(self.llm, "last_usage"): + usage = self.llm.last_usage + elif "usage" in state.metadata: + usage = state.metadata["usage"] + + yield { + "type": "complete", + "result": { + "entry_id": entry_id, + "summary_text": state.summary or "", + "status": "success", + "provider": self.provider_name, + "model": self.model_name, + }, + "usage": usage, + } + + async def _stream_llm_text(self, prompt: str) -> AsyncIterator[str]: + """优先使用 provider 的 token 流式输出,否则一次性返回。""" + if isinstance(self.llm, LLMProviderAdapter): + messages = [ChatMessage(role="user", content=prompt)] + result = await self.llm._provider.chat(messages, stream=True) + if hasattr(result, "__aiter__"): + async for token in result: + yield token + return + yield result.content + return + + text = await self.llm.chat(prompt) + yield text + def _provider_name_from_base_url(base_url: str) -> str: if not base_url: diff --git a/backend/agent_summary/http/router.py b/backend/agent_summary/http/router.py index d48867a..5567a8a 100644 --- a/backend/agent_summary/http/router.py +++ b/backend/agent_summary/http/router.py @@ -1,6 +1,10 @@ """HTTP routes for summary agent requests.""" +import json +import logging + from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse from app.schemas.agent import SummaryRequest, SummaryResult @@ -11,6 +15,8 @@ SummaryService, ) +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/agents/summary", tags=["agent-summary"]) @@ -29,3 +35,41 @@ async def generate_summary(request: SummaryRequest) -> SummaryResult: raise HTTPException(status_code=409, detail="Entry has no summary content") from exc except LLMClientError as exc: raise HTTPException(status_code=502, detail=exc.detail) from exc + + +@router.post("/stream") +async def generate_summary_stream(request: SummaryRequest) -> StreamingResponse: + service = get_summary_service() + + try: + stream = service.stream_generate(request) + first_event = await stream.__anext__() + + async def event_stream(): + first_payload = json.dumps(first_event, ensure_ascii=False) + yield f"event: {first_event.get('type', 'message')}\ndata: {first_payload}\n\n" + async for event in stream: + event_type = event.get("type", "message") + payload = json.dumps(event, ensure_ascii=False) + yield f"event: {event_type}\ndata: {payload}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + + except ArticleNotFoundError as exc: + logger.warning("Streaming summary request for missing article: %s", request.entry_id) + raise HTTPException(status_code=404, detail="Entry not found") from exc + except ArticleContentUnavailableError as exc: + logger.warning( + "Streaming summary request for article without content: %s", + request.entry_id, + ) + raise HTTPException(status_code=409, detail="Entry has no summary content") from exc + except LLMClientError as exc: + raise HTTPException(status_code=502, detail=exc.detail) from exc diff --git a/backend/agent_summary/service.py b/backend/agent_summary/service.py index de2ad28..25dac3a 100644 --- a/backend/agent_summary/service.py +++ b/backend/agent_summary/service.py @@ -2,13 +2,14 @@ import os import re +from collections.abc import AsyncIterator from dataclasses import dataclass -from datetime import UTC +from datetime import UTC, datetime from app.schemas.agent import SummaryRequest, SummaryResult from db import get_article, get_article_content, record_usage, save_agent_result from llm_providers import ChatMessage, get_provider -from llm_providers.base import LLMProvider +from llm_providers.base import LLMProvider, LLMProviderError, ProviderNotFoundError from .agent.summary_agent import SummaryAgent @@ -67,6 +68,101 @@ async def generate(self, request: SummaryRequest) -> SummaryResult: return summary + async def stream_generate(self, request: SummaryRequest) -> AsyncIterator[dict]: + article = get_article(request.entry_id) + if article is None: + raise ArticleNotFoundError(request.entry_id) + + content = self._resolve_content(request.entry_id, article.reader_html) + if not content: + raise ArticleContentUnavailableError(request.entry_id) + + async for event in self._execute_summary_stream( + entry_id=request.entry_id, + content=content, + request=request, + ): + yield event + + async def _execute_summary_stream( + self, + entry_id: str, + content: str, + request: SummaryRequest, + ) -> AsyncIterator[dict]: + provider_name = request.provider + model_name = request.model + + try: + agent = self._build_agent(request) + provider_name = agent.provider_name + model_name = agent.model_name + + async for event in agent.stream_summarize(entry_id, content): + if event.get("type") != "complete": + yield event + continue + + usage = event.get("usage", {}) + result_payload = event["result"] + summary = SummaryResult( + entry_id=result_payload["entry_id"], + summary_text=result_payload["summary_text"], + status=result_payload["status"], + provider=result_payload["provider"], + model=result_payload["model"], + ) + save_agent_result(summary) + + if usage: + today = datetime.now(UTC).strftime("%Y-%m-%d") + record_usage( + day=today, + provider=summary.provider, + model=summary.model, + agent="summary", + prompt_tokens=usage.get("prompt_tokens", 0), + completion_tokens=usage.get("completion_tokens", 0), + ) + + yield { + "type": "complete", + "result": summary.model_dump(), + } + + except ProviderNotFoundError: + result = SummaryResult( + entry_id=entry_id, + summary_text="", + status="failure", + provider=provider_name or "unknown", + model=model_name or "unknown", + ) + save_agent_result(result) + yield {"type": "complete", "result": result.model_dump()} + + except LLMProviderError: + result = SummaryResult( + entry_id=entry_id, + summary_text="", + status="failure", + provider=provider_name or "unknown", + model=model_name or "unknown", + ) + save_agent_result(result) + yield {"type": "complete", "result": result.model_dump()} + + except Exception: + result = SummaryResult( + entry_id=entry_id, + summary_text="", + status="failure", + provider=provider_name or "unknown", + model=model_name or "unknown", + ) + save_agent_result(result) + yield {"type": "complete", "result": result.model_dump()} + def _build_agent(self, request: SummaryRequest) -> SummaryAgent: if self.agent_factory is not SummaryAgent and not request.provider and not request.model: return _instantiate_agent(self.agent_factory) diff --git a/backend/agent_summary/tests/test_agent/test_summary_agent.py b/backend/agent_summary/tests/test_agent/test_summary_agent.py index 09d9a51..01bea47 100644 --- a/backend/agent_summary/tests/test_agent/test_summary_agent.py +++ b/backend/agent_summary/tests/test_agent/test_summary_agent.py @@ -64,3 +64,13 @@ async def test_mock_llm(self): llm = MockLLM() response = await llm.chat("Test prompt") assert response == "Test prompt" + + @pytest.mark.anyio + async def test_stream_summarize_emits_chunk_events(self, short_article): + agent = SummaryAgent(use_mock=True) + events = [event async for event in agent.stream_summarize("test-stream", short_article)] + + assert events[0]["type"] == "start" + assert any(event["type"] == "chunk" for event in events) + assert events[-1]["type"] == "complete" + assert events[-1]["result"]["summary_text"] diff --git a/backend/tests/test_http_contract.py b/backend/tests/test_http_contract.py index f00b2a3..593e149 100644 --- a/backend/tests/test_http_contract.py +++ b/backend/tests/test_http_contract.py @@ -304,6 +304,68 @@ async def stream_generate(self, request): assert 'event: complete\ndata: {"type": "complete"' in body +def test_stream_summary_emits_incremental_events(api_client: TestClient, monkeypatch) -> None: + summary_router_module = importlib.import_module("agent_summary.http.router") + from agent_summary.service import SummaryService + + summary_text = "First chunk summary.\n\nSecond chunk summary." + + class FakeSummaryService(SummaryService): + async def stream_generate(self, request): + yield { + "type": "start", + "entry_id": request.entry_id, + "provider": "mock", + "model": "mock-model", + "strategy": "hierarchical", + } + yield { + "type": "chunk", + "chunk_index": 0, + "delta_text": "First chunk summary.", + "summary_text": "First chunk summary.", + "phase": "chunk", + "failed": False, + } + yield { + "type": "chunk", + "chunk_index": 1, + "delta_text": "Second chunk summary.", + "summary_text": summary_text, + "phase": "chunk", + "failed": False, + } + yield { + "type": "complete", + "result": { + "entry_id": request.entry_id, + "summary_text": summary_text, + "status": "success", + "provider": "mock", + "model": "mock-model", + }, + } + + monkeypatch.setattr( + summary_router_module, + "get_summary_service", + lambda: FakeSummaryService(), + ) + + response = api_client.post( + "/agents/summary/stream", + json={"entry_id": "article-1"}, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + body = response.text + assert 'event: start\ndata: {"type": "start"' in body + assert 'event: chunk\ndata: {"type": "chunk"' in body + assert json.dumps("First chunk summary.", ensure_ascii=False)[1:-1] in body + assert 'event: complete\ndata: {"type": "complete"' in body + + def _article(article_id: str, reader_html: str) -> Entry: return Entry( id=article_id, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 4f60062..41db571 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -267,7 +267,7 @@ export function App() { clearMessages: clearFeedActionMessages } = useFeedActions(reload); const { ensureCleaned } = useEntryCleaner(refreshEntry); - const { status: summaryStatus, errorMessage: summaryError, runSummary, clearError: clearSummaryError } = useSummaryAction(refreshEntry); + const { status: summaryStatus, errorMessage: summaryError, runSummary, clearError: clearSummaryError } = useSummaryAction(refreshEntry, updateEntry); const { setReadState, setReadStateForEntries, toggleStar, deleteOne } = useEntryActions(setData, reload); const [sidebarWidth, setSidebarWidth] = useState(() => readStoredNumber(sidebarWidthKey, 252)); const [listWidth, setListWidth] = useState(() => readStoredNumber(listWidthKey, 360)); diff --git a/packages/ui/src/hooks/useSummaryAction.test.tsx b/packages/ui/src/hooks/useSummaryAction.test.tsx index 48130f5..811b940 100644 --- a/packages/ui/src/hooks/useSummaryAction.test.tsx +++ b/packages/ui/src/hooks/useSummaryAction.test.tsx @@ -3,35 +3,43 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useSummaryAction } from "./useSummaryAction"; -const requestSummary = vi.fn(); +const requestSummaryStream = vi.fn(); vi.mock("../services/api", () => ({ getApiErrorMessage: (error: unknown) => (error instanceof Error ? error.message : "Request failed"), - requestSummary: (...args: unknown[]) => requestSummary(...args) + requestSummaryStream: (...args: unknown[]) => requestSummaryStream(...args) })); describe("useSummaryAction", () => { beforeEach(() => { - requestSummary.mockReset(); + requestSummaryStream.mockReset(); }); - it("runs summary generation and refreshes the entry", async () => { - requestSummary.mockResolvedValue({ summary_text: "Stored summary" }); + it("streams summary chunks and refreshes the entry", async () => { + requestSummaryStream.mockImplementation(async (_entryId, handlers) => { + handlers.onChunk({ summary_text: "Partial summary" }); + handlers.onComplete({ + summary_text: "Stored summary", + status: "success" + }); + }); const refreshEntry = vi.fn().mockResolvedValue({ id: "entry-1" }); - const { result } = renderHook(() => useSummaryAction(refreshEntry)); + const updateEntry = vi.fn(); + const { result } = renderHook(() => useSummaryAction(refreshEntry, updateEntry)); await act(async () => { await result.current.runSummary("entry-1"); }); - expect(requestSummary).toHaveBeenCalledWith("entry-1"); + expect(requestSummaryStream).toHaveBeenCalledWith("entry-1", expect.any(Object)); + expect(updateEntry).toHaveBeenCalled(); expect(refreshEntry).toHaveBeenCalledWith("entry-1"); expect(result.current.status).toBe("idle"); expect(result.current.errorMessage).toBeNull(); }); it("captures API errors without throwing", async () => { - requestSummary.mockRejectedValue(new Error("Entry not found")); + requestSummaryStream.mockRejectedValue(new Error("Entry not found")); const refreshEntry = vi.fn(); const { result } = renderHook(() => useSummaryAction(refreshEntry)); diff --git a/packages/ui/src/hooks/useSummaryAction.ts b/packages/ui/src/hooks/useSummaryAction.ts index b5b96bc..d46fab3 100644 --- a/packages/ui/src/hooks/useSummaryAction.ts +++ b/packages/ui/src/hooks/useSummaryAction.ts @@ -1,24 +1,52 @@ import { useCallback, useState } from "react"; import type { Entry } from "../domain/types"; -import { getApiErrorMessage, requestSummary } from "../services/api"; +import { getApiErrorMessage, requestSummaryStream } from "../services/api"; -export function useSummaryAction(onEntryRefresh: (entryId: string) => Promise) { +export function useSummaryAction( + onEntryRefresh: (entryId: string) => Promise, + onUpdateEntry?: (entryId: string, transform: (entry: Entry) => Entry) => void +) { const [status, setStatus] = useState<"idle" | "running" | "error">("idle"); const [errorMessage, setErrorMessage] = useState(null); const runSummary = useCallback(async (entryId: string) => { setStatus("running"); setErrorMessage(null); + onUpdateEntry?.(entryId, (entry) => ({ ...entry, summaryText: "" })); + try { - await requestSummary(entryId); + let finalStatus: string | null = null; + await requestSummaryStream(entryId, { + onChunk: (event) => { + onUpdateEntry?.(entryId, (entry) => ({ + ...entry, + summaryText: event.summary_text + })); + }, + onComplete: (result) => { + finalStatus = result.status; + onUpdateEntry?.(entryId, (entry) => ({ + ...entry, + summaryText: result.summary_text + })); + } + }); + await onEntryRefresh(entryId); + + if (finalStatus !== "success") { + setStatus("error"); + setErrorMessage("Summary generation failed"); + return; + } + setStatus("idle"); } catch (error) { setStatus("error"); setErrorMessage(getApiErrorMessage(error)); } - }, [onEntryRefresh]); + }, [onEntryRefresh, onUpdateEntry]); const clearError = useCallback(() => { setErrorMessage(null); diff --git a/packages/ui/src/services/api/index.ts b/packages/ui/src/services/api/index.ts index 18dff33..8fb2674 100644 --- a/packages/ui/src/services/api/index.ts +++ b/packages/ui/src/services/api/index.ts @@ -67,6 +67,100 @@ export async function requestSummary(entryId: string): Promise void; + onChunk: (event: SummaryStreamChunkEvent) => void; + onComplete: (result: components["schemas"]["SummaryResult"]) => void; + } +): Promise { + const response = await fetch(`${backendBaseUrl}/agents/summary/stream`, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ entry_id: entryId }) + }); + + if (!response.ok) { + const text = await response.text(); + const parsed = text.length > 0 ? JSON.parse(text) : undefined; + throw new IpcError(response.status, response.url, parsed); + } + + if (!response.body) { + throw new Error("Streaming response body is unavailable"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + + const events = buffer.split("\n\n"); + buffer = events.pop() ?? ""; + + for (const rawEvent of events) { + const event = parseSummarySseEvent(rawEvent); + if (!event) { + continue; + } + if (event.type === "start") { + handlers.onStart?.(event); + } else if (event.type === "chunk") { + handlers.onChunk(event); + } else if (event.type === "complete") { + handlers.onComplete(event.result); + } + } + + if (done) { + if (buffer.trim()) { + const event = parseSummarySseEvent(buffer); + if (event?.type === "start") { + handlers.onStart?.(event); + } else if (event?.type === "chunk") { + handlers.onChunk(event); + } else if (event?.type === "complete") { + handlers.onComplete(event.result); + } + } + break; + } + } +} + export async function requestTranslation( entryId: string, targetLang: string @@ -242,6 +336,14 @@ function hasDetail(value: unknown): value is { detail?: unknown } { } function parseSseEvent(rawEvent: string): TranslationStreamEvent | null { + return parseTypedSseEvent(rawEvent); +} + +function parseSummarySseEvent(rawEvent: string): SummaryStreamEvent | null { + return parseTypedSseEvent(rawEvent); +} + +function parseTypedSseEvent(rawEvent: string): T | null { const lines = rawEvent .split(/\r?\n/) .map((line) => line.trimEnd()) @@ -261,7 +363,7 @@ function parseSseEvent(rawEvent: string): TranslationStreamEvent | null { return null; } - const parsed = JSON.parse(dataLines.join("\n")) as TranslationStreamEvent; + const parsed = JSON.parse(dataLines.join("\n")) as T; if (parsed.type !== eventType && parsed.type) { return parsed; }