Skip to content

Commit 2489c48

Browse files
authored
Merge pull request #72 from ks6088ts-labs/feature/issue-70_news-summarizer
add news summarizer agent
2 parents 13e6fb5 + 013d04a commit 2489c48

File tree

8 files changed

+892
-595
lines changed

8 files changed

+892
-595
lines changed

langgraph.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"chat_with_tools_agent": "template_langgraph.agents.chat_with_tools_agent.agent:graph",
88
"kabuto_helpdesk_agent": "template_langgraph.agents.kabuto_helpdesk_agent.agent:graph",
99
"issue_formatter_agent": "template_langgraph.agents.issue_formatter_agent.agent:graph",
10-
"task_decomposer_agent": "template_langgraph.agents.task_decomposer_agent.agent:graph"
10+
"task_decomposer_agent": "template_langgraph.agents.task_decomposer_agent.agent:graph",
11+
"news_summarizer_agent": "template_langgraph.agents.news_summarizer_agent.agent:graph"
1112
},
1213
"env": ".env"
1314
}

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,6 @@ environment = { python-version = "3.10" }
8080
unknown-argument = "ignore"
8181
invalid-parameter-default = "ignore"
8282
non-subscriptable = "ignore"
83+
possibly-unbound-attribute = "ignore"
84+
unresolved-attribute = "ignore"
85+
invalid-argument-type = "ignore"

scripts/agent_operator.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import logging
2+
from uuid import uuid4
23

34
import typer
45
from dotenv import load_dotenv
56

67
from template_langgraph.agents.chat_with_tools_agent.agent import graph as chat_with_tools_agent_graph
78
from template_langgraph.agents.issue_formatter_agent.agent import graph as issue_formatter_agent_graph
89
from template_langgraph.agents.kabuto_helpdesk_agent.agent import graph as kabuto_helpdesk_agent_graph
10+
from template_langgraph.agents.news_summarizer_agent.agent import (
11+
graph as news_summarizer_agent_graph,
12+
)
913
from template_langgraph.agents.task_decomposer_agent.agent import graph as task_decomposer_agent_graph
1014
from template_langgraph.loggers import get_logger
1115

@@ -28,6 +32,8 @@ def get_agent_graph(name: str):
2832
return task_decomposer_agent_graph
2933
elif name == "kabuto_helpdesk_agent":
3034
return kabuto_helpdesk_agent_graph
35+
elif name == "news_summarizer_agent":
36+
return news_summarizer_agent_graph
3137
else:
3238
raise ValueError(f"Unknown agent name: {name}")
3339

@@ -90,6 +96,10 @@ def run(
9096
if verbose:
9197
logger.setLevel(logging.DEBUG)
9298

99+
assert name not in [
100+
"news_summarizer_agent",
101+
], f"{name} is not supported. Please use another agent."
102+
93103
graph = get_agent_graph(name)
94104
for event in graph.stream(
95105
input={
@@ -105,6 +115,62 @@ def run(
105115
logger.info(f"Event: {event}")
106116

107117

118+
@app.command()
119+
def news_summarizer_agent(
120+
request: str = typer.Option(
121+
"Please summarize the latest news articles in Japanese briefly in 3 sentences.",
122+
"--request",
123+
"-r",
124+
help="Request to the agent",
125+
),
126+
urls: str = typer.Option(
127+
"https://example.com/article1,https://example.com/article2",
128+
"--urls",
129+
"-u",
130+
help="Comma-separated list of URLs to summarize",
131+
),
132+
verbose: bool = typer.Option(
133+
False,
134+
"--verbose",
135+
"-v",
136+
help="Enable verbose output",
137+
),
138+
):
139+
from template_langgraph.agents.news_summarizer_agent.models import (
140+
AgentInputState,
141+
AgentOutputState,
142+
AgentState,
143+
)
144+
145+
# Set up logging
146+
if verbose:
147+
logger.setLevel(logging.DEBUG)
148+
149+
graph = news_summarizer_agent_graph
150+
for event in graph.stream(
151+
input=AgentState(
152+
input=AgentInputState(
153+
request=request,
154+
request_id=str(uuid4()),
155+
urls=urls.split(",") if urls else [],
156+
),
157+
output=AgentOutputState(
158+
result="N/A",
159+
articles=[],
160+
),
161+
target_url_index=None,
162+
)
163+
):
164+
logger.info("-" * 20)
165+
logger.info(f"Event: {event}")
166+
167+
output: AgentOutputState = event["notify"]["output"]
168+
for article in output.articles:
169+
logger.info(article.url)
170+
logger.info(f"is_valid_url: {article.is_valid_url}, is_valid_content: {article.is_valid_content}")
171+
logger.info(article.structured_article.model_dump_json(indent=2))
172+
173+
108174
if __name__ == "__main__":
109175
load_dotenv(
110176
override=True,

scripts/test_all.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ AGENT_NAMES=(
3535
"issue_formatter_agent"
3636
"kabuto_helpdesk_agent"
3737
"task_decomposer_agent"
38+
"news_summarizer_agent"
3839
)
3940
for AGENT_NAME in "${AGENT_NAMES[@]}"; do
4041
uv run python scripts/agent_operator.py png --name "$AGENT_NAME" --verbose --output "generated/${AGENT_NAME}.png"

template_langgraph/agents/news_summarizer_agent/__init__.py

Whitespace-only changes.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import httpx
2+
from langgraph.graph import StateGraph
3+
from langgraph.types import Send
4+
5+
from template_langgraph.agents.news_summarizer_agent.models import AgentState, Article, StructuredArticle
6+
from template_langgraph.llms.azure_openais import AzureOpenAiWrapper
7+
from template_langgraph.loggers import get_logger
8+
9+
logger = get_logger(__name__)
10+
11+
12+
class MockNotifier:
13+
def notify(self, request_id: str, body: dict) -> None:
14+
"""Simulate sending a notification to the user."""
15+
logger.info(f"Notification sent for request {request_id}: {body}")
16+
17+
18+
class MockScraper:
19+
def scrape(self, url: str) -> str:
20+
"""Simulate scraping a web page."""
21+
return "<html><body><h1>Mocked web content</h1></body></html>"
22+
23+
24+
class HttpxScraper:
25+
def scrape(self, url: str) -> str:
26+
"""Retrieve the HTML content of a web page."""
27+
with httpx.Client() as client:
28+
response = client.get(url)
29+
response.raise_for_status()
30+
return response.text
31+
32+
33+
class MockSummarizer:
34+
def summarize(
35+
self,
36+
prompt: str,
37+
content: str,
38+
) -> StructuredArticle:
39+
"""Simulate summarizing the input."""
40+
return StructuredArticle(
41+
title="Mocked Title",
42+
date="2023-01-01",
43+
summary=f"Mocked summary of the content: {content}, prompt: {prompt}",
44+
keywords=["mock", "summary"],
45+
score=75,
46+
)
47+
48+
49+
class LlmSummarizer:
50+
def __init__(self, llm=AzureOpenAiWrapper().chat_model):
51+
self.llm = llm
52+
53+
def summarize(
54+
self,
55+
prompt: str,
56+
content: str,
57+
) -> StructuredArticle:
58+
"""Use the LLM to summarize the input."""
59+
logger.info(f"Summarizing input with LLM: {prompt}")
60+
return self.llm.with_structured_output(StructuredArticle).invoke(
61+
input=[
62+
{"role": "system", "content": prompt},
63+
{"role": "user", "content": content},
64+
]
65+
)
66+
67+
68+
class NewsSummarizerAgent:
69+
def __init__(
70+
self,
71+
llm=AzureOpenAiWrapper().chat_model,
72+
notifier=MockNotifier(),
73+
scraper=MockScraper(),
74+
summarizer=MockSummarizer(),
75+
):
76+
self.llm = llm
77+
self.notifier = notifier
78+
self.scraper = scraper
79+
self.summarizer = summarizer
80+
81+
def create_graph(self):
82+
"""Create the main graph for the agent."""
83+
# Create the workflow state graph
84+
workflow = StateGraph(AgentState)
85+
86+
# Create nodes
87+
workflow.add_node("initialize", self.initialize)
88+
workflow.add_node("fetch_web_content", self.fetch_web_content)
89+
workflow.add_node("notify", self.notify)
90+
91+
# Create edges
92+
workflow.set_entry_point("initialize")
93+
workflow.add_conditional_edges(
94+
source="initialize",
95+
path=self.run_subtasks,
96+
)
97+
workflow.add_edge("fetch_web_content", "notify")
98+
workflow.set_finish_point("notify")
99+
return workflow.compile(
100+
name=NewsSummarizerAgent.__name__,
101+
)
102+
103+
def initialize(self, state: AgentState) -> AgentState:
104+
"""Initialize the agent state."""
105+
logger.info(f"Initializing state: {state}")
106+
# FIXME: retrieve urls from user request
107+
return state
108+
109+
def run_subtasks(self, state: AgentState) -> list[Send]:
110+
"""Run the subtasks for the agent."""
111+
logger.info(f"Running subtasks with state: {state}")
112+
return [
113+
Send(
114+
node="fetch_web_content",
115+
arg=AgentState(
116+
input=state.input,
117+
output=state.output,
118+
target_url_index=idx,
119+
),
120+
)
121+
for idx, _ in enumerate(state.input.urls)
122+
]
123+
124+
def fetch_web_content(self, state: AgentState):
125+
url: str = state.input.urls[state.target_url_index]
126+
is_valid_url = url.startswith("http")
127+
is_valid_content = False
128+
content = ""
129+
130+
# Check if the URL is valid
131+
if not is_valid_url:
132+
logger.error(f"Invalid URL: {url}")
133+
is_valid_content = False
134+
else:
135+
# Scrape the web content
136+
try:
137+
logger.info(f"Scraping URL: {url}")
138+
content = self.scraper.scrape(url)
139+
is_valid_content = True
140+
except httpx.RequestError as e:
141+
logger.error(f"Error fetching web content: {e}")
142+
143+
if is_valid_content:
144+
logger.info(f"Summarizing content with LLM @ {state.target_url_index}: {url}")
145+
structured_article: StructuredArticle = self.summarizer.summarize(
146+
prompt=state.input.request,
147+
content=content,
148+
)
149+
state.output.articles.append(
150+
Article(
151+
is_valid_url=is_valid_url,
152+
is_valid_content=is_valid_content,
153+
content=content,
154+
url=url,
155+
structured_article=structured_article,
156+
),
157+
)
158+
159+
def notify(self, state: AgentState) -> AgentState:
160+
"""Send notifications to the user."""
161+
logger.info(f"Sending notifications with state: {state}")
162+
# Simulate sending notifications
163+
# convert list of articles to a dictionary for notification
164+
summary = {}
165+
for i, article in enumerate(state.output.articles):
166+
summary[i] = article.model_dump()
167+
self.notifier.notify(
168+
request_id=state.input.request_id,
169+
body=summary,
170+
)
171+
return state
172+
173+
174+
# For testing
175+
# graph = NewsSummarizerAgent().create_graph()
176+
177+
graph = NewsSummarizerAgent(
178+
notifier=MockNotifier(),
179+
scraper=HttpxScraper(),
180+
summarizer=LlmSummarizer(),
181+
).create_graph()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from pydantic import BaseModel, Field
2+
3+
4+
class StructuredArticle(BaseModel):
5+
title: str = Field(..., description="Title of the article")
6+
date: str = Field(..., description="Publication date of the article")
7+
summary: str = Field(..., description="Summary of the article")
8+
keywords: list[str] = Field(..., description="Keywords extracted from the article")
9+
score: int = Field(..., description="Score of the article based on user request from 0 to 100")
10+
11+
12+
class Article(BaseModel):
13+
is_valid_url: bool = Field(..., description="Indicates if the article URL is valid")
14+
is_valid_content: bool = Field(..., description="Indicates if the article content is valid")
15+
content: str = Field(..., description="Original content of the article")
16+
url: str = Field(..., description="URL of the article")
17+
structured_article: StructuredArticle = Field(..., description="Structured representation of the article")
18+
19+
20+
class AgentInputState(BaseModel):
21+
request: str = Field(..., description="Request from the user")
22+
request_id: str = Field(..., description="Unique identifier for the request")
23+
urls: list[str] = Field(..., description="List of article URLs")
24+
25+
26+
class AgentOutputState(BaseModel):
27+
articles: list[Article] = Field(..., description="List of articles processed by the agent")
28+
29+
30+
class AgentState(BaseModel):
31+
input: AgentInputState = Field(..., description="Input state for the agent")
32+
output: AgentOutputState = Field(..., description="Output state for the agent")
33+
target_url_index: int | None = Field(..., description="Index of the target URL being processed")

0 commit comments

Comments
 (0)