Synthesis prompt with citation rules
Everything so far has been plumbing. This is the node that actually answers the question. The prompt needs to do two jobs at once: tell the LLM how to respond, and force it to cite the sources we provide. Without citation discipline, users cannot verify anything.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
def synthesis_node(state):
state["current_step"] = "synthesizing"
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, max_tokens=1200)
prompt = ChatPromptTemplate.from_messages([
("system", """You are a helpful research assistant that provides accurate, well-cited answers based on search results.
Guidelines:
1. Provide a comprehensive answer that directly addresses the question
2. Cite sources using [1], [2], etc.
3. Be factual and objective
4. Include relevant details and context
5. If information is conflicting, mention this
6. If information is insufficient, acknowledge limitations
Structure:
- Start with a direct answer
- Provide detailed explanation with citations
- Include key facts and figures
- End with a concise summary"""),
("human", """Question: {question}
Search Results:
{context}
Please provide a comprehensive answer based on the above sources. Cite sources using [1], [2], etc.""")
])The system message sets the role and the citation contract. Temperature 0 keeps factual answers stable across runs. The system-human split is the shape LangChain expects.
Quick note on syntax you are about to see: prompt | llm | StrOutputParser(). LangChain overloads the pipe operator to compose components into a single runnable chain, a style it calls LCEL. When you invoke the chain with a dict, the prompt template fills in its placeholders, the filled prompt goes to the model, and the parser turns the model response into a plain string. Three steps, one call.
# Build the context string the LLM will read
context = ""
for item in state["extracted_content"]:
context += f"\nSource [{item['source_id']}]:\n"
context += f"Title: {item['title']}\n"
context += f"URL: {item['url']}\n"
context += f"Snippet: {item['snippet']}\n"
context += f"Content: {item['content'][:800]}...\n" + "-" * 50
chain = prompt | llm | StrOutputParser()
response = chain.invoke({
"question": state["question"],
"context": context,
})
state["synthesized_answer"] = responseEach source is labeled with its source_id, the same number the LLM will use in citations. Truncating content to 800 chars per source keeps context tight enough for a small model to follow the instructions.
Creative writing benefits from higher temperatures. Factual synthesis does not. At 0, the model picks the most likely next token every time, which produces stable, repeatable answers grounded in the context. Ask the same question twice and you get the same answer. That predictability is exactly what you want for a research tool.
Quiz: Quiz
Loading practice…