AgentBench / agents /writer.py
Adityax-07's picture
feat: add core LangGraph multi-agent pipeline
be745f3
Raw
History Blame Contribute Delete
7.92 kB
"""
WRITER AGENT
============
Purpose:
This agent takes structured analysis and writes a polished final report.
Why this agent exists:
The analyst extracts WHAT is true.
The writer decides HOW to communicate it clearly.
These are genuinely different cognitive tasks — merging them into one
agent produces mediocre output at both. Keeping them separate lets each
LLM call focus on one thing and do it well.
Why the writer is the LAST agent in the pipeline:
By this stage, all facts have been verified (researcher) and insights
have been structured (analyst). The writer has everything it needs and
cannot introduce hallucinations — it only reformats real data.
"""
# =========================
# Imports
# =========================
from langchain_groq import ChatGroq
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel
from typing import List
# We import AnalysisResult so the writer receives typed data,
# not raw strings. This enforces the pipeline contract.
# WHY typed input instead of a plain string?
# If the analyst changes its output schema, Python will catch
# the mismatch immediately rather than silently producing bad reports.
from agents.analyst import AnalysisResult
# =========================
# 1️⃣ Structured Output Schema
# =========================
# WHY:
# Even though the writer's job is to produce prose, we still
# wrap the output in a Pydantic model.
#
# This gives us:
# • a machine-readable title (useful for saving/displaying)
# • the report body as a clean string
# • a list of sources (important for credibility)
# • word count for quality checks downstream
class FinalReport(BaseModel):
# Short descriptive title for the report
title: str
# Full markdown-formatted report body
body: str
# Key sources cited (extracted from analyst insights)
sources_cited: List[str]
# Approximate word count — useful for trimming or expanding later
word_count: int
# =========================
# 2️⃣ Writer Function
# =========================
def run_writer(
analysis: AnalysisResult,
original_query: str,
subtasks: List[str]
) -> FinalReport:
"""
Convert structured analysis → polished final report.
Parameters
----------
analysis : AnalysisResult
Output from the analyst agent (insights, summary, code, confidence).
original_query : str
The user's original question.
WHY pass this again?
The writer needs the exact phrasing to write an opening that
directly answers the user — not just summarizes the research.
subtasks : List[str]
The subtasks from the planner's ResearchPlan.
WHY include these?
They act as section headers — the writer structures the report
around the plan, ensuring every subtask is addressed.
Returns
-------
FinalReport
A fully structured, human-readable research report.
"""
# =========================
# Step A: Build Context String
# =========================
# WHY pre-format before the prompt?
# Injecting raw Python objects into prompts produces messy output.
# We convert the analysis into clean, readable text sections
# so the LLM receives well-structured context — not dict reprs.
# Format key insights as a numbered list for the LLM
insights_text = "\n".join(
f"{i+1}. {insight}"
for i, insight in enumerate(analysis.key_insights)
)
# Format the planner's subtasks so the writer knows what to cover
subtasks_text = "\n".join(
f"- {task}" for task in subtasks
)
# Only include code section if something was actually computed
# WHY conditional inclusion?
# Injecting an empty "No code output" section wastes tokens
# and can confuse the LLM into inventing data tables.
code_section = (
f"Data / computation results:\n{analysis.code_output}"
if analysis.code_output
else ""
)
# =========================
# Step B: LLM Setup
# =========================
# WHY claude-sonnet-4-6 again (same as analyst)?
# Writing requires the same reasoning depth as analysis.
# We need coherent structure, good prose, and accurate citations.
# A cheaper model (Haiku) would produce noticeably worse writing.
llm = ChatGroq(
model="llama-3.3-70b-versatile"
).with_structured_output(FinalReport)
# =========================
# Step C: Prompt Construction
# =========================
# WHY such a detailed system message?
# The system message defines the persona and output contract.
# "Research report writer" → formal, structured, sourced prose.
# Without this framing, the LLM defaults to a generic chatbot tone.
#
# We also explicitly tell it to use markdown — because the report
# will be rendered in Streamlit / frontend, not printed as plain text.
prompt = ChatPromptTemplate.from_messages([
(
"system",
"You are a professional research report writer. "
"Write clear, well-structured markdown reports. "
"Use the analyst's insights as your source of truth — do not invent facts. "
"Structure the report with: an introduction, one section per subtask, "
"a conclusion, and a sources section. "
"Confidence level from the analyst should be noted in the conclusion."
),
(
"human",
"User's original question:\n{query}\n\n"
"Research subtasks to cover:\n{subtasks}\n\n"
"Key insights from analyst:\n{insights}\n\n"
"Overall summary from analyst:\n{summary}\n\n"
"{code_section}"
"\nAnalyst confidence: {confidence}\n\n"
"Write a complete research report now."
)
])
# =========================
# Step D: Run the Chain
# =========================
# WHY same LCEL pattern as planner and analyst?
# Consistency across agents makes the codebase easy to maintain.
# Anyone reading this file can immediately understand the pattern.
report = (prompt | llm).invoke({
"query": original_query,
"subtasks": subtasks_text,
"insights": insights_text,
"summary": analysis.summary,
"code_section": code_section,
"confidence": analysis.confidence
})
return report
# =========================
# Example test
# =========================
if __name__ == "__main__":
# Simulate analyst output for offline testing
# WHY not call the real analyst here?
# We want this file to be testable in isolation,
# without needing API keys for the full pipeline.
mock_analysis = AnalysisResult(
key_insights=[
"AI engineer salaries range from $120k–$300k in the US.",
"Demand for ML roles grew 35% year-over-year.",
"Python and PyTorch are the most sought-after skills.",
"Google, OpenAI, and Microsoft are the top employers.",
],
summary=(
"The AI job market is rapidly expanding with strong salary growth "
"and increasing demand across major tech companies."
),
code_output="Mean salary: $205,000",
confidence="high"
)
mock_subtasks = [
"Research current AI job market trends",
"Find salary ranges for AI/ML roles",
"Identify top employers hiring AI talent",
"Compute average salary from data"
]
report = run_writer(
analysis=mock_analysis,
original_query="Analyze AI job trends and create a salary chart",
subtasks=mock_subtasks
)
print("\n=== Final Report ===")
print(f"Title: {report.title}")
print(f"Word Count: {report.word_count}")
print(f"Sources: {report.sources_cited}")
print(f"\n{report.body}")