MicroFish/backend/app/services/report_agent.py

2724 lines
103 KiB
Python

"""
Report Agent service.
Uses LangChain + local graph store to implement a ReACT-pattern simulation report generator.
Features:
1. Generate reports based on simulation requirements and graph store information.
2. Plan the outline structure first, then generate section by section.
3. Each section uses a ReACT multi-round thinking-and-reflection pattern.
4. Support chatting with the user, autonomously invoking retrieval tools during the conversation.
"""
import os
import json
import time
import re
from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from ..config import Config
from ..utils.llm_client import LLMClient
from ..utils.logger import get_logger
from ..utils.locale import get_language_instruction, t
from .graph_tools import (
GraphToolsService,
SearchResult,
InsightForgeResult,
PanoramaResult,
InterviewResult,
)
logger = get_logger("mirofish.report_agent")
class ReportLogger:
"""
Detailed logger for the Report Agent.
Generates an agent_log.jsonl file in the report folder, recording every
detailed action. Each line is a complete JSON object containing a timestamp,
action type, detailed content, etc.
"""
def __init__(self, report_id: str):
"""
Initialize the logger.
Args:
report_id: Report ID, used to determine the log file path.
"""
self.report_id = report_id
self.log_file_path = os.path.join(
Config.UPLOAD_FOLDER, "reports", report_id, "agent_log.jsonl"
)
self.start_time = datetime.now()
self._ensure_log_file()
def _ensure_log_file(self):
"""Ensure the directory containing the log file exists."""
log_dir = os.path.dirname(self.log_file_path)
os.makedirs(log_dir, exist_ok=True)
def _get_elapsed_time(self) -> float:
"""Return elapsed time in seconds since the start."""
return (datetime.now() - self.start_time).total_seconds()
def log(
self,
action: str,
stage: str,
details: Dict[str, Any],
section_title: str = None,
section_index: int = None,
):
"""
Record a single log entry.
Args:
action: Action type, e.g. 'start', 'tool_call', 'llm_response',
'section_complete', etc.
stage: Current stage, e.g. 'planning', 'generating', 'completed'.
details: Detailed content dict, not truncated.
section_title: Title of the current section (optional).
section_index: Index of the current section (optional).
"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"elapsed_seconds": round(self._get_elapsed_time(), 2),
"report_id": self.report_id,
"action": action,
"stage": stage,
"section_title": section_title,
"section_index": section_index,
"details": details,
}
# Append to JSONL file
with open(self.log_file_path, "a", encoding="utf-8") as f:
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
def log_start(self, simulation_id: str, graph_id: str, simulation_requirement: str):
"""Record the start of report generation."""
self.log(
action="report_start",
stage="pending",
details={
"simulation_id": simulation_id,
"graph_id": graph_id,
"simulation_requirement": simulation_requirement,
"message": t("report.taskStarted"),
},
)
def log_planning_start(self):
"""Record the start of outline planning."""
self.log(
action="planning_start",
stage="planning",
details={"message": t("report.planningStart")},
)
def log_planning_context(self, context: Dict[str, Any]):
"""Record the context information obtained during planning."""
self.log(
action="planning_context",
stage="planning",
details={"message": t("report.fetchSimContext"), "context": context},
)
def log_planning_complete(self, outline_dict: Dict[str, Any]):
"""Record outline planning completion."""
self.log(
action="planning_complete",
stage="planning",
details={"message": t("report.planningComplete"), "outline": outline_dict},
)
def log_section_start(self, section_title: str, section_index: int):
"""Record the start of section generation."""
self.log(
action="section_start",
stage="generating",
section_title=section_title,
section_index=section_index,
details={"message": t("report.sectionStart", title=section_title)},
)
def log_react_thought(
self, section_title: str, section_index: int, iteration: int, thought: str
):
"""Record a ReACT thinking step."""
self.log(
action="react_thought",
stage="generating",
section_title=section_title,
section_index=section_index,
details={
"iteration": iteration,
"thought": thought,
"message": t("report.reactThought", iteration=iteration),
},
)
def log_tool_call(
self,
section_title: str,
section_index: int,
tool_name: str,
parameters: Dict[str, Any],
iteration: int,
):
"""Record a tool call."""
self.log(
action="tool_call",
stage="generating",
section_title=section_title,
section_index=section_index,
details={
"iteration": iteration,
"tool_name": tool_name,
"parameters": parameters,
"message": t("report.toolCall", toolName=tool_name),
},
)
def log_tool_result(
self,
section_title: str,
section_index: int,
tool_name: str,
result: str,
iteration: int,
):
"""Record a tool call result (full content, not truncated)."""
self.log(
action="tool_result",
stage="generating",
section_title=section_title,
section_index=section_index,
details={
"iteration": iteration,
"tool_name": tool_name,
"result": result, # Full result, no truncation
"result_length": len(result),
"message": t("report.toolResult", toolName=tool_name),
},
)
def log_llm_response(
self,
section_title: str,
section_index: int,
response: str,
iteration: int,
has_tool_calls: bool,
has_final_answer: bool,
):
"""Record an LLM response (full content, not truncated)."""
self.log(
action="llm_response",
stage="generating",
section_title=section_title,
section_index=section_index,
details={
"iteration": iteration,
"response": response, # Full response, no truncation
"response_length": len(response),
"has_tool_calls": has_tool_calls,
"has_final_answer": has_final_answer,
"message": t(
"report.llmResponse",
hasToolCalls=has_tool_calls,
hasFinalAnswer=has_final_answer,
),
},
)
def log_section_content(
self,
section_title: str,
section_index: int,
content: str,
tool_calls_count: int,
):
"""Record that section content generation is complete (records content only; does not mean the whole section is done)."""
self.log(
action="section_content",
stage="generating",
section_title=section_title,
section_index=section_index,
details={
"content": content, # Full content, no truncation
"content_length": len(content),
"tool_calls_count": tool_calls_count,
"message": t("report.sectionContentDone", title=section_title),
},
)
def log_section_full_complete(
self, section_title: str, section_index: int, full_content: str
):
"""
Record that section generation is complete.
The frontend should listen for this log to determine whether a section
is truly finished and to obtain the full content.
"""
self.log(
action="section_complete",
stage="generating",
section_title=section_title,
section_index=section_index,
details={
"content": full_content,
"content_length": len(full_content),
"message": t("report.sectionComplete", title=section_title),
},
)
def log_report_complete(self, total_sections: int, total_time_seconds: float):
"""Record report generation completion."""
self.log(
action="report_complete",
stage="completed",
details={
"total_sections": total_sections,
"total_time_seconds": round(total_time_seconds, 2),
"message": t("report.reportComplete"),
},
)
def log_error(self, error_message: str, stage: str, section_title: str = None):
"""Record an error."""
self.log(
action="error",
stage=stage,
section_title=section_title,
section_index=None,
details={
"error": error_message,
"message": t("report.errorOccurred", error=error_message),
},
)
class ReportConsoleLogger:
"""
Console logger for the Report Agent.
Writes console-style logs (INFO, WARNING, etc.) to a console_log.txt file
inside the report folder. These logs are plain-text console output, distinct
from agent_log.jsonl.
"""
def __init__(self, report_id: str):
"""
Initialize the console logger.
Args:
report_id: Report ID, used to determine the log file path.
"""
self.report_id = report_id
self.log_file_path = os.path.join(
Config.UPLOAD_FOLDER, "reports", report_id, "console_log.txt"
)
self._ensure_log_file()
self._file_handler = None
self._setup_file_handler()
def _ensure_log_file(self):
"""Ensure the directory containing the log file exists."""
log_dir = os.path.dirname(self.log_file_path)
os.makedirs(log_dir, exist_ok=True)
def _setup_file_handler(self):
"""Set up the file handler so logs are also written to the file."""
import logging
# Create file handler
self._file_handler = logging.FileHandler(
self.log_file_path, mode="a", encoding="utf-8"
)
self._file_handler.setLevel(logging.INFO)
# Use same concise format as console
formatter = logging.Formatter(
"[%(asctime)s] %(levelname)s: %(message)s", datefmt="%H:%M:%S"
)
self._file_handler.setFormatter(formatter)
# Add to report_agent logger
loggers_to_attach = [
"mirofish.report_agent",
"mirofish.graph_tools",
]
for logger_name in loggers_to_attach:
target_logger = logging.getLogger(logger_name)
# Avoid duplicate handlers
if self._file_handler not in target_logger.handlers:
target_logger.addHandler(self._file_handler)
def close(self):
"""Close the file handler and remove it from the loggers."""
import logging
if self._file_handler:
loggers_to_detach = [
"mirofish.report_agent",
"mirofish.graph_tools",
]
for logger_name in loggers_to_detach:
target_logger = logging.getLogger(logger_name)
if self._file_handler in target_logger.handlers:
target_logger.removeHandler(self._file_handler)
self._file_handler.close()
self._file_handler = None
def __del__(self):
"""Ensure the file handler is closed on destruction."""
self.close()
class ReportStatus(str, Enum):
"""Report status."""
PENDING = "pending"
PLANNING = "planning"
GENERATING = "generating"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class ReportSection:
"""A report section."""
title: str
content: str = ""
def to_dict(self) -> Dict[str, Any]:
return {"title": self.title, "content": self.content}
def to_markdown(self, level: int = 2) -> str:
"""Convert to Markdown format."""
md = f"{'#' * level} {self.title}\n\n"
if self.content:
md += f"{self.content}\n\n"
return md
@dataclass
class ReportOutline:
"""Report outline."""
title: str
summary: str
sections: List[ReportSection]
def to_dict(self) -> Dict[str, Any]:
return {
"title": self.title,
"summary": self.summary,
"sections": [s.to_dict() for s in self.sections],
}
def to_markdown(self) -> str:
"""Convert to Markdown format."""
md = f"# {self.title}\n\n"
md += f"> {self.summary}\n\n"
for section in self.sections:
md += section.to_markdown()
return md
@dataclass
class Report:
"""A complete report."""
report_id: str
simulation_id: str
graph_id: str
simulation_requirement: str
status: ReportStatus
outline: Optional[ReportOutline] = None
markdown_content: str = ""
created_at: str = ""
completed_at: str = ""
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"report_id": self.report_id,
"simulation_id": self.simulation_id,
"graph_id": self.graph_id,
"simulation_requirement": self.simulation_requirement,
"status": self.status.value,
"outline": self.outline.to_dict() if self.outline else None,
"markdown_content": self.markdown_content,
"created_at": self.created_at,
"completed_at": self.completed_at,
"error": self.error,
}
# ═══════════════════════════════════════════════════════════════
# Prompt template constants
# ═══════════════════════════════════════════════════════════════
# ── Tool Descriptions ──
TOOL_DESC_INSIGHT_FORGE = """\
[Deep Insight Retrieval - Powerful Retrieval Tool]
This is our powerful retrieval function, designed for deep analysis. It will:
1. Automatically decompose your question into multiple sub-questions
2. Retrieve information from the simulation graph across multiple dimensions
3. Integrate results from semantic search, entity analysis, and relationship chain tracing
4. Return the most comprehensive and in-depth retrieval content
[Use Cases]
- When you need to deeply analyze a topic
- When you need to understand multiple aspects of an event
- When you need to gather rich material to support a report section
[Returned Content]
- Relevant fact originals (directly quotable)
- Core entity insights
- Relationship chain analysis"""
TOOL_DESC_PANORAMA_SEARCH = """\
[Breadth Search - Get the Full Picture View]
This tool is used to get the complete picture of simulation results, especially suited for understanding how events evolve. It will:
1. Retrieve all relevant nodes and relationships
2. Distinguish currently valid facts from historical/expired facts
3. Help you understand how public opinion evolved
[Use Cases]
- When you need to understand the complete development trajectory of an event
- When you need to compare changes in public opinion across different stages
- When you need to obtain comprehensive entity and relationship information
[Returned Content]
- Currently valid facts (latest simulation results)
- Historical/expired facts (evolution records)
- All involved entities"""
TOOL_DESC_QUICK_SEARCH = """\
[Quick Search - Fast Retrieval]
A lightweight fast retrieval tool, suitable for simple, direct information queries.
[Use Cases]
- When you need to quickly find a specific piece of information
- When you need to verify a fact
- Simple information retrieval
[Returned Content]
- A list of facts most relevant to the query"""
TOOL_DESC_INTERVIEW_AGENTS = """\
[In-Depth Interview - Real Agent Interviewing (Dual-Platform)]
Calls the OASIS simulation environment's interview API to conduct real interviews with running simulation agents!
This is not an LLM simulation, but a call to the real interview interface to get raw answers from simulation agents.
By default, interviews are conducted simultaneously on both Twitter and Reddit platforms to obtain more comprehensive views.
Functional flow:
1. Automatically reads persona files to understand all simulation agents
2. Intelligently selects the agents most relevant to the interview topic (e.g., students, media, officials, etc.)
3. Automatically generates interview questions
4. Calls the /api/simulation/interview/batch interface to conduct real interviews on both platforms
5. Integrates all interview results to provide multi-perspective analysis
[Use Cases]
- When you need to understand views on an event from different role perspectives (What do students think? What does the media think? What do officials say?)
- When you need to collect multiple opinions and positions
- When you need to get real answers from simulation agents (from the OASIS simulation environment)
- When you want to make the report more vivid by including "interview transcripts"
[Returned Content]
- Identity information of interviewed agents
- Each agent's interview answers on both Twitter and Reddit platforms
- Key quotes (directly quotable)
- Interview summaries and perspective comparisons
[Important] Requires the OASIS simulation environment to be running to use this feature!"""
# ── Outline Planning Prompt ──
PLAN_SYSTEM_PROMPT = """\
You are an expert writer of "Future Prediction Reports", possessing a "God's-eye view" of the simulated world — you can observe the behavior, statements, and interactions of every Agent in the simulation.
[Core Concept]
We built a simulated world and injected specific "simulation requirements" into it as variables. The evolution outcomes of the simulated world are predictions of what may happen in the future. What you are observing is not "experimental data", but a "preview of the future".
[Your Task]
Write a "Future Prediction Report" that answers:
1. Under the conditions we set, what happened in the future?
2. How did various types of Agents (populations) react and act?
3. What noteworthy future trends and risks does this simulation reveal?
[Report Positioning]
- ✅ This is a simulation-based future prediction report, revealing "if this happens, what will the future look like"
- ✅ Focus on prediction results: event trajectories, group reactions, emergent phenomena, potential risks
- ✅ The words and actions of Agents in the simulated world are predictions of future population behavior
- ❌ Not an analysis of the current state of the real world
- ❌ Not a generic public opinion overview
[Section Count Limit]
- Minimum 2 sections, maximum 5 sections
- No subsections needed; write complete content directly for each section
- Content should be concise, focused on core predictive findings
- Section structure is designed autonomously by you based on prediction results
Please output the report outline in JSON format, as follows:
{
"title": "Report Title",
"summary": "Report Summary (one sentence summarizing the core predictive finding)",
"sections": [
{
"title": "Section Title",
"description": "Section Content Description"
}
]
}
Note: The sections array must have a minimum of 2 and a maximum of 5 elements!"""
PLAN_USER_PROMPT_TEMPLATE = """\
[Prediction Scenario Setting]
The variable we injected into the simulated world (simulation requirement): {simulation_requirement}
[Simulated World Scale]
- Number of entities participating in the simulation: {total_nodes}
- Number of relationships between entities: {total_edges}
- Entity type distribution: {entity_types}
- Number of active agents: {total_entities}
[Sample of Future Facts Predicted by the Simulation]
{related_facts_json}
Please review this future preview from a "God's-eye view":
1. Under the conditions we set, what state did the future present?
2. How did various populations (Agents) react and act?
3. What noteworthy future trends does this simulation reveal?
Based on the prediction results, design the most appropriate report section structure.
[Reminder] Report section count: minimum 2, maximum 5, content should be concise and focused on core predictive findings."""
# ── Section Generation Prompt ──
SECTION_SYSTEM_PROMPT_TEMPLATE = """\
You are an expert writer of "Future Prediction Reports", currently writing one section of the report.
Report Title: {report_title}
Report Summary: {report_summary}
Prediction Scenario (Simulation Requirement): {simulation_requirement}
Current Section to Write: {section_title}
═══════════════════════════════════════════════════════════════
[Core Concept]
═══════════════════════════════════════════════════════════════
The simulated world is a preview of the future. We injected specific conditions (simulation requirements) into the simulated world,
and the behavior and interactions of Agents in the simulation are predictions of future population behavior.
Your tasks are:
- Reveal what happened in the future under the set conditions
- Predict how various populations (Agents) will react and act
- Discover noteworthy future trends, risks, and opportunities
❌ Do not write an analysis of the current state of the real world
✅ Focus on "what will the future look like" — simulation results are the predicted future
═══════════════════════════════════════════════════════════════
[Most Important Rules - Must Be Followed]
═══════════════════════════════════════════════════════════════
1. [Must Call Tools to Observe the Simulated World]
- You are observing a preview of the future from a "God's-eye view"
- All content must come from events and Agent statements that occurred in the simulated world
- Forbidden to use your own knowledge to write report content
- Each section must call tools at least 3 times (maximum 5 times) to observe the simulated world, which represents the future
2. [Must Quote Agents' Original Statements and Actions]
- Agents' statements and actions are predictions of future population behavior
- Use quote format in the report to present these predictions, for example:
> "A certain group will state: original content..."
- These quotes are the core evidence of the simulation's predictions
3. [Language Consistency - Quoted Content Must Be Translated to the Report Language]
- Content returned by tools may contain expressions in a different language from the report
- The report must be written entirely in the language consistent with the user-specified language
- When you quote content returned by tools in other languages, you must translate it into the report language before writing it
- Maintain the original meaning during translation and ensure natural and fluent expression
- This rule applies equally to body text and content within quote blocks (> format)
4. [Faithfully Present Prediction Results]
- Report content must reflect the simulation results in the simulated world that represent the future
- Do not add information that does not exist in the simulation
- If information is insufficient in some aspect, state so honestly
═══════════════════════════════════════════════════════════════
[⚠️ Format Specifications - Extremely Important!]
═══════════════════════════════════════════════════════════════
[One Section = Minimum Content Unit]
- Each section is the minimum block unit of the report
- ❌ Forbidden to use any Markdown headings (#, ##, ###, ####, etc.) within a section
- ❌ Forbidden to add the section main title at the beginning of content
- ✅ Section titles are automatically added by the system; you only need to write pure body content
- ✅ Use **bold**, paragraph breaks, quotes, lists to organize content, but do not use headings
[Correct Example]
```
This section analyzes the public opinion dissemination trend of the event. Through in-depth analysis of simulation data, we found...
**Initial Outbreak Stage**
As the first scene of public opinion, Weibo undertook the core function of initial information release:
> "Weibo contributed 68% of the initial voice volume..."
**Emotion Amplification Stage**
The Douyin platform further amplified the event's impact:
- Strong visual impact
- High emotional resonance
```
[Wrong Example]
```
## Executive Summary ← Wrong! Do not add any headings
### I. Initial Stage ← Wrong! Do not use ### for subsections
#### 1.1 Detailed Analysis ← Wrong! Do not use #### for subdivisions
This section analyzes...
```
═══════════════════════════════════════════════════════════════
[Available Retrieval Tools] (Call 3-5 times per section)
═══════════════════════════════════════════════════════════════
{tools_description}
[Tool Usage Suggestions - Please Mix Different Tools, Do Not Use Only One]
- insight_forge: Deep insight analysis, automatically decomposes questions and retrieves facts and relationships across multiple dimensions
- panorama_search: Wide-angle panoramic search, understand the full picture, timeline, and evolution of events
- quick_search: Quickly verify a specific information point
- interview_agents: Interview simulation agents to obtain first-person views and real reactions from different roles
═══════════════════════════════════════════════════════════════
[Workflow]
═══════════════════════════════════════════════════════════════
Each reply you can only do one of the following two things (not both at the same time):
Option A - Call a tool:
Output your thought, then call a tool using the following format:
<tool>
{{"name": "tool_name", "parameters": {{"param_name": "param_value"}}}}
</tool>
The system will execute the tool and return the result to you. You do not need to and cannot write tool return results yourself.
Option B - Output final content:
When you have obtained enough information through tools, output the section content starting with "Final Answer:".
⚠️ Strictly forbidden:
- Forbidden to include both a tool call and a Final Answer in the same reply
- Forbidden to fabricate tool return results (Observation) yourself; all tool results are injected by the system
- At most one tool call per reply
═══════════════════════════════════════════════════════════════
[Section Content Requirements]
═══════════════════════════════════════════════════════════════
1. Content must be based on simulation data retrieved by tools
2. Extensively quote original text to demonstrate simulation effects
3. Use Markdown format (but headings are forbidden):
- Use **bold text** to highlight key points (instead of subheadings)
- Use lists (- or 1.2.3.) to organize points
- Use blank lines to separate different paragraphs
- ❌ Forbidden to use any heading syntax such as #, ##, ###, ####
4. [Quote Format Specification - Must Be a Separate Paragraph]
Quotes must be independent paragraphs, with a blank line before and after each, and cannot be mixed within paragraphs:
✅ Correct format:
```
The school's response was considered to lack substance.
> "The school's response pattern appeared rigid and sluggish in the rapidly changing social media environment."
This assessment reflects the public's general dissatisfaction.
```
❌ Wrong format:
```
The school's response was considered to lack substance.> "The school's response pattern..." This assessment reflects...
```
5. Maintain logical coherence with other sections
6. [Avoid Repetition] Carefully read the completed section content below and do not repeat the same information
7. [Reiterated] Do not add any headings! Use **bold** instead of subheadings"""
SECTION_USER_PROMPT_TEMPLATE = """\
Completed section content (please read carefully to avoid repetition):
{previous_content}
═══════════════════════════════════════════════════════════════
[Current Task] Write section: {section_title}
═══════════════════════════════════════════════════════════════
[Important Reminders]
1. Carefully read the completed sections above to avoid repeating the same content!
2. Must call tools to get simulation data before starting
3. Please mix different tools, do not use only one
4. Report content must come from retrieval results, do not use your own knowledge
[⚠️ Format Warning - Must Be Followed]
- ❌ Do not write any headings (#, ##, ###, #### are all not allowed)
- ❌ Do not write "{section_title}" as the beginning
- ✅ Section titles are automatically added by the system
- ✅ Write body text directly, use **bold** instead of subheadings
Please begin:
1. First think (Thought) about what information this section needs
2. Then call a tool (Action) to get simulation data
3. After collecting enough information, output Final Answer (pure body text, no headings at all)"""
# ── ReACT Loop Message Templates ──
REACT_OBSERVATION_TEMPLATE = """\
Observation (Retrieval Result):
═══ Tool {tool_name} Returned ═══
{result}
═══════════════════════════════════════════════════════════════
Tools called {tool_calls_count}/{max_tool_calls} times (used: {used_tools_str}){unused_hint}
- If information is sufficient: output section content starting with "Final Answer:" (must quote the original text above)
- If more information is needed: call a tool to continue retrieving
═══════════════════════════════════════════════════════════════"""
REACT_INSUFFICIENT_TOOLS_MSG = (
"[Note] You have only called tools {tool_calls_count} times; at least {min_tool_calls} times are required."
"Please call tools again to get more simulation data, then output Final Answer. {unused_hint}"
)
REACT_INSUFFICIENT_TOOLS_MSG_ALT = (
"Currently only {tool_calls_count} tool calls have been made; at least {min_tool_calls} are required."
"Please call a tool to get simulation data. {unused_hint}"
)
REACT_TOOL_LIMIT_MSG = (
"Tool call limit reached ({tool_calls_count}/{max_tool_calls}); cannot call tools anymore."
'Please immediately output section content starting with "Final Answer:" based on the information already obtained.'
)
REACT_UNUSED_TOOLS_HINT = "\n💡 You have not yet used: {unused_list}; it is recommended to try different tools to get multi-angle information"
REACT_FORCE_FINAL_MSG = "Tool call limit reached. Please output Final Answer: directly and generate the section content."
# ── Chat prompt ──
CHAT_SYSTEM_PROMPT_TEMPLATE = """\
You are a concise and efficient simulation prediction assistant.
[Background]
Prediction conditions: {simulation_requirement}
[Generated Analysis Report]
{report_content}
[Rules]
1. Prioritize answering questions based on the report content above
2. Answer questions directly; avoid lengthy reasoning and discussion
3. Only call tools to retrieve more data when the report content is insufficient to answer
4. Answers should be concise, clear, and well-organized
[Available Tools] (use only when needed, at most 1-2 calls)
{tools_description}
[Tool Call Format]
<tool>
{{"name": "tool_name", "parameters": {{"param_name": "param_value"}}}}
</tool>
[Answer Style]
- Concise and direct; no long-winded explanations
- Use > format to quote key content
- Give conclusions first, then explain reasons"""
CHAT_OBSERVATION_SUFFIX = "\n\nPlease answer the question concisely."
# ═══════════════════════════════════════════════════════════════
# ReportAgent main class
# ═══════════════════════════════════════════════════════════════
class ReportAgent:
"""
Report Agent - the simulation-report-generation agent.
Uses a ReACT (Reasoning + Acting) pattern:
1. Planning stage: analyze the simulation requirement and plan the report outline.
2. Generation stage: generate content section by section; each section may call
tools multiple times to retrieve information.
3. Reflection stage: check content completeness and accuracy.
"""
# Max tool calls (per section)
MAX_TOOL_CALLS_PER_SECTION = 5
# Max reflection rounds
MAX_REFLECTION_ROUNDS = 3
# Max tool calls in conversation
MAX_TOOL_CALLS_PER_CHAT = 2
def __init__(
self,
graph_id: str,
simulation_id: str,
simulation_requirement: str,
llm_client: Optional[LLMClient] = None,
graph_tools: Optional[GraphToolsService] = None,
):
"""
Initialize the Report Agent.
Args:
graph_id: Graph ID.
simulation_id: Simulation ID.
simulation_requirement: Description of the simulation requirement.
llm_client: LLM client (optional).
graph_tools: Graph tools service (optional).
"""
self.graph_id = graph_id
self.simulation_id = simulation_id
self.simulation_requirement = simulation_requirement
self.llm = llm_client or LLMClient()
self.graph_tools = graph_tools or GraphToolsService()
# Tool definitions
self.tools = self._define_tools()
# Logger (initialized in generate_report)
self.report_logger: Optional[ReportLogger] = None
# Console logger (initialized in generate_report)
self.console_logger: Optional[ReportConsoleLogger] = None
logger.info(
t("report.agentInitDone", graphId=graph_id, simulationId=simulation_id)
)
def _define_tools(self) -> Dict[str, Dict[str, Any]]:
"""Define the available tools."""
return {
"insight_forge": {
"name": "insight_forge",
"description": TOOL_DESC_INSIGHT_FORGE,
"parameters": {
"query": "The question or topic you want to deeply analyze",
"report_context": "Context of the current report section (optional, helps generate more precise sub-questions)",
},
},
"panorama_search": {
"name": "panorama_search",
"description": TOOL_DESC_PANORAMA_SEARCH,
"parameters": {
"query": "Search query, used for relevance ranking",
"include_expired": "Whether to include expired/historical content (default True)",
},
},
"quick_search": {
"name": "quick_search",
"description": TOOL_DESC_QUICK_SEARCH,
"parameters": {
"query": "Search query string",
"limit": "Number of results to return (optional, default 10)",
},
},
"interview_agents": {
"name": "interview_agents",
"description": TOOL_DESC_INTERVIEW_AGENTS,
"parameters": {
"interview_topic": "Interview topic or requirement description (e.g.: 'Understand students' views on the dormitory formaldehyde incident')",
"max_agents": "Maximum number of agents to interview (optional, default 5, max 10)",
},
},
}
def _execute_tool(
self, tool_name: str, parameters: Dict[str, Any], report_context: str = ""
) -> str:
"""
Execute a tool call.
Args:
tool_name: Tool name.
parameters: Tool parameters.
report_context: Report context (used by InsightForge).
Returns:
Tool execution result (text format).
"""
logger.info(t("report.executingTool", toolName=tool_name, params=parameters))
try:
if tool_name == "insight_forge":
query = parameters.get("query", "")
ctx = parameters.get("report_context", "") or report_context
result = self.graph_tools.insight_forge(
graph_id=self.graph_id,
query=query,
simulation_requirement=self.simulation_requirement,
report_context=ctx,
)
return result.to_text()
elif tool_name == "panorama_search":
# Broad search - get full picture
query = parameters.get("query", "")
include_expired = parameters.get("include_expired", True)
if isinstance(include_expired, str):
include_expired = include_expired.lower() in ["true", "1", "yes"]
result = self.graph_tools.panorama_search(
graph_id=self.graph_id, query=query, include_expired=include_expired
)
return result.to_text()
elif tool_name == "quick_search":
# Simple search - fast retrieval
query = parameters.get("query", "")
limit = parameters.get("limit", 10)
if isinstance(limit, str):
limit = int(limit)
result = self.graph_tools.quick_search(
graph_id=self.graph_id, query=query, limit=limit
)
return result.to_text()
elif tool_name == "interview_agents":
# Deep interview - call real OASIS interview API for simulated Agent responses (dual platform)
interview_topic = parameters.get(
"interview_topic", parameters.get("query", "")
)
max_agents = parameters.get("max_agents", 5)
if isinstance(max_agents, str):
max_agents = int(max_agents)
max_agents = min(max_agents, 10)
result = self.graph_tools.interview_agents(
simulation_id=self.simulation_id,
interview_requirement=interview_topic,
simulation_requirement=self.simulation_requirement,
max_agents=max_agents,
)
return result.to_text()
# ========== Backward-compatible legacy tools (internally redirected to new tools) ==========
elif tool_name == "search_graph":
# Redirect to quick_search
logger.info(t("report.redirectToQuickSearch"))
return self._execute_tool("quick_search", parameters, report_context)
elif tool_name == "get_graph_statistics":
result = self.graph_tools.get_graph_statistics(self.graph_id)
return json.dumps(result, ensure_ascii=False, indent=2)
elif tool_name == "get_entity_summary":
entity_name = parameters.get("entity_name", "")
result = self.graph_tools.get_entity_summary(
graph_id=self.graph_id, entity_name=entity_name
)
return json.dumps(result, ensure_ascii=False, indent=2)
elif tool_name == "get_simulation_context":
# Redirect to insight_forge (more powerful)
logger.info(t("report.redirectToInsightForge"))
query = parameters.get("query", self.simulation_requirement)
return self._execute_tool(
"insight_forge", {"query": query}, report_context
)
elif tool_name == "get_entities_by_type":
entity_type = parameters.get("entity_type", "")
nodes = self.graph_tools.get_entities_by_type(
graph_id=self.graph_id, entity_type=entity_type
)
result = [n.to_dict() for n in nodes]
return json.dumps(result, ensure_ascii=False, indent=2)
else:
return f"{t('api.unknownTool', name=tool_name)}"
except Exception as e:
logger.error(t("report.toolExecFailed", toolName=tool_name, error=str(e)))
return f"Tool execution failed: {str(e)}"
# Set of valid tool names for bare JSON fallback parsing validation
VALID_TOOL_NAMES = {
"insight_forge",
"panorama_search",
"quick_search",
"interview_agents",
}
def _parse_tool_calls(self, response: str) -> List[Dict[str, Any]]:
"""
Parse tool calls from the LLM response.
Supported formats (in priority order):
1. <tool>{"name": "tool_name", "parameters": {...}}</tool>
2. Bare JSON (the whole response or a single line is a tool-call JSON)
"""
tool_calls = []
# Format 1: XML style (standard format)
xml_pattern = r"<tool_call>\s*(\{.*?\})\s*</tool_call>"
for match in re.finditer(xml_pattern, response, re.DOTALL):
try:
call_data = json.loads(match.group(1))
tool_calls.append(call_data)
except json.JSONDecodeError:
pass
if tool_calls:
return tool_calls
# Format 2: fallback - LLM outputs bare JSON directly (no <tool_call> wrapper)
# Only try if format 1 didn't match, to avoid false matching JSON in body text
stripped = response.strip()
if stripped.startswith("{") and stripped.endswith("}"):
try:
call_data = json.loads(stripped)
if self._is_valid_tool_call(call_data):
tool_calls.append(call_data)
return tool_calls
except json.JSONDecodeError:
pass
# Response may contain thinking text + bare JSON, try extracting last JSON object
json_pattern = r'(\{"(?:name|tool)"\s*:.*?\})\s*$'
match = re.search(json_pattern, stripped, re.DOTALL)
if match:
try:
call_data = json.loads(match.group(1))
if self._is_valid_tool_call(call_data):
tool_calls.append(call_data)
except json.JSONDecodeError:
pass
return tool_calls
def _is_valid_tool_call(self, data: dict) -> bool:
"""Validate whether the parsed JSON is a legitimate tool call."""
# Support both {"name": ..., "parameters": ...} and {"tool": ..., "params": ...} key names
tool_name = data.get("name") or data.get("tool")
if tool_name and tool_name in self.VALID_TOOL_NAMES:
# Normalize key names to name / parameters
if "tool" in data:
data["name"] = data.pop("tool")
if "params" in data and "parameters" not in data:
data["parameters"] = data.pop("params")
return True
return False
def _get_tools_description(self) -> str:
"""Generate the tool description text."""
desc_parts = ["Available tools:"]
for name, tool in self.tools.items():
params_desc = ", ".join(
[f"{k}: {v}" for k, v in tool["parameters"].items()]
)
desc_parts.append(f"- {name}: {tool['description']}")
if params_desc:
desc_parts.append(f" Parameters: {params_desc}")
return "\n".join(desc_parts)
@staticmethod
def _strip_fake_tool_results(response: str) -> str:
"""Strip any <tool_result> blocks the LLM fabricated in its response.
When the LLM generates a tool call block and then continues to generate
a <tool_result> block in the same response, we must strip the fake result
before appending to message history. The real tool result will be injected
separately by the system.
"""
tag_pattern = re.compile(r'</?tool_result\b[^>]*>', flags=re.IGNORECASE)
parts = []
cursor = 0
depth = 0
for match in tag_pattern.finditer(response):
if depth == 0:
parts.append(response[cursor:match.start()])
if match.group(0).lstrip().startswith('</'):
depth = max(0, depth - 1)
else:
depth += 1
cursor = match.end()
if depth == 0:
parts.append(response[cursor:])
cleaned = ''.join(parts)
# Treat a malformed opening tag without a closing > as unsafe too.
cleaned = re.sub(r'<tool_result\b.*$', '', cleaned, flags=re.IGNORECASE | re.DOTALL)
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
return cleaned.strip()
def plan_outline(
self, progress_callback: Optional[Callable] = None
) -> ReportOutline:
"""
Plan the report outline.
Uses the LLM to analyze the simulation requirement and plan the
report's directory structure.
Args:
progress_callback: Progress callback function.
Returns:
ReportOutline: The report outline.
"""
logger.info(t("report.startPlanningOutline"))
if progress_callback:
progress_callback("planning", 0, t("progress.analyzingRequirements"))
# First get simulation context
context = self.graph_tools.get_simulation_context(
graph_id=self.graph_id, simulation_requirement=self.simulation_requirement
)
if progress_callback:
progress_callback("planning", 30, t("progress.generatingOutline"))
system_prompt = f"{PLAN_SYSTEM_PROMPT}\n\n{get_language_instruction()}"
user_prompt = PLAN_USER_PROMPT_TEMPLATE.format(
simulation_requirement=self.simulation_requirement,
total_nodes=context.get("graph_statistics", {}).get("total_nodes", 0),
total_edges=context.get("graph_statistics", {}).get("total_edges", 0),
entity_types=list(
context.get("graph_statistics", {}).get("entity_types", {}).keys()
),
total_entities=context.get("total_entities", 0),
related_facts_json=json.dumps(
context.get("related_facts", [])[:10], ensure_ascii=False, indent=2
),
)
try:
response = self.llm.chat_json(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.3,
)
if progress_callback:
progress_callback("planning", 80, t("progress.parsingOutline"))
# Parse outline
sections = []
for section_data in response.get("sections", []):
sections.append(
ReportSection(title=section_data.get("title", ""), content="")
)
outline = ReportOutline(
title=response.get("title", t("api.defaultReportTitle")),
summary=response.get("summary", ""),
sections=sections,
)
if progress_callback:
progress_callback("planning", 100, t("progress.outlinePlanComplete"))
logger.info(t("report.outlinePlanDone", count=len(sections)))
return outline
except Exception as e:
logger.error(t("report.outlinePlanFailed", error=str(e)))
# Return default outline (3 sections, as fallback)
return ReportOutline(
title="Future Prediction Report",
summary="Analysis of Future Trends and Risks Based on Simulation Predictions",
sections=[
ReportSection(title="Prediction Scenario and Core Findings"),
ReportSection(title="Population Behavior Prediction Analysis"),
ReportSection(title="Trend Outlook and Risk Warnings"),
],
)
def _generate_section_react(
self,
section: ReportSection,
outline: ReportOutline,
previous_sections: List[str],
progress_callback: Optional[Callable] = None,
section_index: int = 0,
) -> str:
"""
Generate a single section's content using the ReACT pattern.
ReACT loop:
1. Thought - analyze what information is needed.
2. Action - call a tool to retrieve information.
3. Observation - analyze the tool's returned result.
4. Repeat until information is sufficient or the maximum count is reached.
5. Final Answer - generate the section content.
Args:
section: The section to generate.
outline: The complete outline.
previous_sections: Contents of previous sections (used to maintain coherence).
progress_callback: Progress callback.
section_index: Section index (used for logging).
Returns:
Section content (Markdown format).
"""
logger.info(t("report.reactGenerateSection", title=section.title))
# Log section start
if self.report_logger:
self.report_logger.log_section_start(section.title, section_index)
system_prompt = SECTION_SYSTEM_PROMPT_TEMPLATE.format(
report_title=outline.title,
report_summary=outline.summary,
simulation_requirement=self.simulation_requirement,
section_title=section.title,
tools_description=self._get_tools_description(),
)
system_prompt = f"{system_prompt}\n\n{get_language_instruction()}"
# Build user prompt - pass up to 4000 chars per completed section
if previous_sections:
previous_parts = []
for sec in previous_sections:
# Max 4000 chars per section
truncated = sec[:4000] + "..." if len(sec) > 4000 else sec
previous_parts.append(truncated)
previous_content = "\n\n---\n\n".join(previous_parts)
else:
previous_content = "(This is the first chapter)"
user_prompt = SECTION_USER_PROMPT_TEMPLATE.format(
previous_content=previous_content,
section_title=section.title,
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
# ReACT loop
tool_calls_count = 0
max_iterations = 5 # Max iterations
min_tool_calls = 3 # Min tool calls
conflict_retries = 0 # Consecutive conflict count (tool call + Final Answer both present)
used_tools = set() # Track called tool names
all_tools = {
"insight_forge",
"panorama_search",
"quick_search",
"interview_agents",
}
# Report context, used for InsightForge sub-query generation
report_context = f"Section title: {section.title}\nSimulation requirement: {self.simulation_requirement}"
for iteration in range(max_iterations):
if progress_callback:
progress_callback(
"generating",
int((iteration / max_iterations) * 100),
t(
"progress.deepSearchAndWrite",
current=tool_calls_count,
max=self.MAX_TOOL_CALLS_PER_SECTION,
),
)
# Call LLM
response = self.llm.chat(
messages=messages, temperature=0.5, max_tokens=8192
)
# Check if LLM returned None (API error or empty content)
if response is None:
logger.warning(
t(
"report.sectionIterNone",
title=section.title,
iteration=iteration + 1,
)
)
# If iterations remain, add message and retry
if iteration < max_iterations - 1:
messages.append(
{"role": "assistant", "content": "(Response was empty)"}
)
messages.append(
{
"role": "user",
"content": "Please continue generating content.",
}
)
continue
# Last iteration also returned None, break loop and force finish
break
logger.debug(f"LLM response: {response[:200]}...")
# Parse once, reuse result
tool_calls = self._parse_tool_calls(response)
has_tool_calls = bool(tool_calls)
has_final_answer = "Final Answer:" in response
# ── Conflict handling: LLM output both tool call and Final Answer ──
if has_tool_calls and has_final_answer:
conflict_retries += 1
logger.warning(
t(
"report.sectionConflict",
title=section.title,
iteration=iteration + 1,
conflictCount=conflict_retries,
)
)
if conflict_retries <= 2:
# First two times: discard this response, ask LLM to re-respond
messages.append({"role": "assistant", "content": response})
messages.append(
{
"role": "user",
"content": (
"[Format Error] You included both a tool call and a Final Answer in the same reply, which is not allowed.\n"
"Each reply can only do one of the following two things:\n"
"- Call a tool (output a <tool> block, do not write Final Answer)\n"
"- Output final content (start with 'Final Answer:', do not include <tool>)\n"
"Please reply again, doing only one of them."
),
}
)
continue
else:
# Third time: downgrade, truncate to first tool call, force execute
logger.warning(
t(
"report.sectionConflictDowngrade",
title=section.title,
conflictCount=conflict_retries,
)
)
first_tool_end = response.find("</tool_call>")
if first_tool_end != -1:
response = response[: first_tool_end + len("</tool_call>")]
tool_calls = self._parse_tool_calls(response)
has_tool_calls = bool(tool_calls)
has_final_answer = False
conflict_retries = 0
# Log LLM response
if self.report_logger:
self.report_logger.log_llm_response(
section_title=section.title,
section_index=section_index,
response=response,
iteration=iteration + 1,
has_tool_calls=has_tool_calls,
has_final_answer=has_final_answer,
)
# ── Case 1: LLM output Final Answer ──
if has_final_answer:
# Insufficient tool calls, reject and ask to continue calling tools
if tool_calls_count < min_tool_calls:
messages.append({"role": "assistant", "content": response})
unused_tools = all_tools - used_tools
unused_hint = (
f"(These tools have not been used yet, recommend trying them: {', '.join(unused_tools)})"
if unused_tools
else ""
)
messages.append(
{
"role": "user",
"content": REACT_INSUFFICIENT_TOOLS_MSG.format(
tool_calls_count=tool_calls_count,
min_tool_calls=min_tool_calls,
unused_hint=unused_hint,
),
}
)
continue
# Normal completion
final_answer = response.split("Final Answer:")[-1].strip()
logger.info(
t(
"report.sectionGenDone",
title=section.title,
count=tool_calls_count,
)
)
if self.report_logger:
self.report_logger.log_section_content(
section_title=section.title,
section_index=section_index,
content=final_answer,
tool_calls_count=tool_calls_count,
)
return final_answer
# ── Case 2: LLM attempting tool call ──
if has_tool_calls:
# Tool quota exhausted → explicitly inform, ask for Final Answer
if tool_calls_count >= self.MAX_TOOL_CALLS_PER_SECTION:
messages.append({"role": "assistant", "content": response})
messages.append(
{
"role": "user",
"content": REACT_TOOL_LIMIT_MSG.format(
tool_calls_count=tool_calls_count,
max_tool_calls=self.MAX_TOOL_CALLS_PER_SECTION,
),
}
)
continue
# Only execute first tool call
call = tool_calls[0]
if len(tool_calls) > 1:
logger.info(
t(
"report.multiToolOnlyFirst",
total=len(tool_calls),
toolName=call["name"],
)
)
if self.report_logger:
self.report_logger.log_tool_call(
section_title=section.title,
section_index=section_index,
tool_name=call["name"],
parameters=call.get("parameters", {}),
iteration=iteration + 1,
)
result = self._execute_tool(
call["name"],
call.get("parameters", {}),
report_context=report_context,
)
if self.report_logger:
self.report_logger.log_tool_result(
section_title=section.title,
section_index=section_index,
tool_name=call["name"],
result=result,
iteration=iteration + 1,
)
tool_calls_count += 1
used_tools.add(call["name"])
# Build unused tools hint
unused_tools = all_tools - used_tools
unused_hint = ""
if unused_tools and tool_calls_count < self.MAX_TOOL_CALLS_PER_SECTION:
unused_hint = REACT_UNUSED_TOOLS_HINT.format(
unused_list=", ".join(unused_tools)
)
messages.append({"role": "assistant", "content": response})
messages.append(
{
"role": "user",
"content": REACT_OBSERVATION_TEMPLATE.format(
tool_name=call["name"],
result=result,
tool_calls_count=tool_calls_count,
max_tool_calls=self.MAX_TOOL_CALLS_PER_SECTION,
used_tools_str=", ".join(used_tools),
unused_hint=unused_hint,
),
}
)
continue
# ── Case 3: Neither tool call nor Final Answer ──
messages.append({"role": "assistant", "content": response})
if tool_calls_count < min_tool_calls:
# Insufficient tool calls, recommend unused tools
unused_tools = all_tools - used_tools
unused_hint = (
f"(These tools have not been used yet, recommend trying them: {', '.join(unused_tools)})"
if unused_tools
else ""
)
messages.append(
{
"role": "user",
"content": REACT_INSUFFICIENT_TOOLS_MSG_ALT.format(
tool_calls_count=tool_calls_count,
min_tool_calls=min_tool_calls,
unused_hint=unused_hint,
),
}
)
continue
# Tool calls sufficient, LLM output content but missing "Final Answer:" prefix
# Use this content directly as final answer, no more idle loops
logger.info(
t("report.sectionNoPrefix", title=section.title, count=tool_calls_count)
)
final_answer = response.strip()
if self.report_logger:
self.report_logger.log_section_content(
section_title=section.title,
section_index=section_index,
content=final_answer,
tool_calls_count=tool_calls_count,
)
return final_answer
# Reached max iterations, force content generation
logger.warning(t("report.sectionMaxIter", title=section.title))
messages.append({"role": "user", "content": REACT_FORCE_FINAL_MSG})
response = self.llm.chat(messages=messages, temperature=0.5, max_tokens=8192)
# Check if force-finish LLM returned None
if response is None:
logger.error(t("report.sectionForceFailed", title=section.title))
final_answer = t("report.sectionGenFailedContent")
elif "Final Answer:" in response:
final_answer = response.split("Final Answer:")[-1].strip()
else:
final_answer = response
# Log section content generation complete
if self.report_logger:
self.report_logger.log_section_content(
section_title=section.title,
section_index=section_index,
content=final_answer,
tool_calls_count=tool_calls_count,
)
return final_answer
def generate_report(
self,
progress_callback: Optional[Callable[[str, int, str], None]] = None,
report_id: Optional[str] = None,
) -> Report:
"""
Generate the full report (streaming output section by section).
Each section is saved to a folder immediately after it is generated,
without waiting for the whole report to complete.
File structure:
reports/{report_id}/
meta.json - report metadata
outline.json - report outline
progress.json - generation progress
section_01.md - section 1
section_02.md - section 2
...
full_report.md - full report
Args:
progress_callback: Progress callback function (stage, progress, message).
report_id: Report ID (optional; auto-generated if not provided).
Returns:
Report: The complete report.
"""
import uuid
# Auto-generate report_id if not provided
if not report_id:
report_id = f"report_{uuid.uuid4().hex[:12]}"
start_time = datetime.now()
report = Report(
report_id=report_id,
simulation_id=self.simulation_id,
graph_id=self.graph_id,
simulation_requirement=self.simulation_requirement,
status=ReportStatus.PENDING,
created_at=datetime.now().isoformat(),
)
# Completed section titles list (for progress tracking)
completed_section_titles = []
try:
# Initialize: create report folder and save initial state
ReportManager._ensure_report_folder(report_id)
# Initialize logger (structured log agent_log.jsonl)
self.report_logger = ReportLogger(report_id)
self.report_logger.log_start(
simulation_id=self.simulation_id,
graph_id=self.graph_id,
simulation_requirement=self.simulation_requirement,
)
# Initialize console logger (console_log.txt)
self.console_logger = ReportConsoleLogger(report_id)
ReportManager.update_progress(
report_id, "pending", 0, t("progress.initReport"), completed_sections=[]
)
ReportManager.save_report(report)
# Phase 1: Plan outline
report.status = ReportStatus.PLANNING
ReportManager.update_progress(
report_id,
"planning",
5,
t("progress.startPlanningOutline"),
completed_sections=[],
)
# Log planning start
self.report_logger.log_planning_start()
if progress_callback:
progress_callback("planning", 0, t("progress.startPlanningOutline"))
outline = self.plan_outline(
progress_callback=lambda stage, prog, msg: (
progress_callback(stage, prog // 5, msg)
if progress_callback
else None
)
)
report.outline = outline
# Log planning complete
self.report_logger.log_planning_complete(outline.to_dict())
# Save outline to file
ReportManager.save_outline(report_id, outline)
ReportManager.update_progress(
report_id,
"planning",
15,
t("progress.outlineDone", count=len(outline.sections)),
completed_sections=[],
)
ReportManager.save_report(report)
logger.info(t("report.outlineSavedToFile", reportId=report_id))
# Phase 2: Generate section by section (save per section)
report.status = ReportStatus.GENERATING
total_sections = len(outline.sections)
generated_sections = [] # Save content for context
for i, section in enumerate(outline.sections):
section_num = i + 1
base_progress = 20 + int((i / total_sections) * 70)
# Update progress
ReportManager.update_progress(
report_id,
"generating",
base_progress,
t(
"progress.generatingSection",
title=section.title,
current=section_num,
total=total_sections,
),
current_section=section.title,
completed_sections=completed_section_titles,
)
if progress_callback:
progress_callback(
"generating",
base_progress,
t(
"progress.generatingSection",
title=section.title,
current=section_num,
total=total_sections,
),
)
# Generate main section content
section_content = self._generate_section_react(
section=section,
outline=outline,
previous_sections=generated_sections,
progress_callback=lambda stage, prog, msg: (
progress_callback(
stage, base_progress + int(prog * 0.7 / total_sections), msg
)
if progress_callback
else None
),
section_index=section_num,
)
section.content = section_content
generated_sections.append(f"## {section.title}\n\n{section_content}")
# Save section
ReportManager.save_section(report_id, section_num, section)
completed_section_titles.append(section.title)
# Log section completion
full_section_content = f"## {section.title}\n\n{section_content}"
if self.report_logger:
self.report_logger.log_section_full_complete(
section_title=section.title,
section_index=section_num,
full_content=full_section_content.strip(),
)
logger.info(
t(
"report.sectionSaved",
reportId=report_id,
sectionNum=f"{section_num:02d}",
)
)
# Update progress
ReportManager.update_progress(
report_id,
"generating",
base_progress + int(70 / total_sections),
t("progress.sectionDone", title=section.title),
current_section=None,
completed_sections=completed_section_titles,
)
# Phase 3: Assemble full report
if progress_callback:
progress_callback("generating", 95, t("progress.assemblingReport"))
ReportManager.update_progress(
report_id,
"generating",
95,
t("progress.assemblingReport"),
completed_sections=completed_section_titles,
)
# Use ReportManager to assemble full report
report.markdown_content = ReportManager.assemble_full_report(
report_id, outline
)
report.status = ReportStatus.COMPLETED
report.completed_at = datetime.now().isoformat()
# Calculate total elapsed time
total_time_seconds = (datetime.now() - start_time).total_seconds()
# Log report completion
if self.report_logger:
self.report_logger.log_report_complete(
total_sections=total_sections, total_time_seconds=total_time_seconds
)
# Save final report
ReportManager.save_report(report)
ReportManager.update_progress(
report_id,
"completed",
100,
t("progress.reportComplete"),
completed_sections=completed_section_titles,
)
if progress_callback:
progress_callback("completed", 100, t("progress.reportComplete"))
logger.info(t("report.reportGenDone", reportId=report_id))
# Close console logger
if self.console_logger:
self.console_logger.close()
self.console_logger = None
return report
except Exception as e:
logger.error(t("report.reportGenFailed", error=str(e)))
report.status = ReportStatus.FAILED
report.error = str(e)
# Log error
if self.report_logger:
self.report_logger.log_error(str(e), "failed")
# Save failure state
try:
ReportManager.save_report(report)
ReportManager.update_progress(
report_id,
"failed",
-1,
t("progress.reportFailed", error=str(e)),
completed_sections=completed_section_titles,
)
except Exception:
pass # Ignore save failure errors
# Close console logger
if self.console_logger:
self.console_logger.close()
self.console_logger = None
return report
def chat(
self, message: str, chat_history: List[Dict[str, str]] = None
) -> Dict[str, Any]:
"""
Chat with the Report Agent.
During the conversation the agent may autonomously call retrieval
tools to answer questions.
Args:
message: User message.
chat_history: Conversation history.
Returns:
{
"response": "Agent reply",
"tool_calls": [list of called tools],
"sources": [information sources]
}
"""
logger.info(t("report.agentChat", message=message[:50]))
chat_history = chat_history or []
# Get generated report content
report_content = ""
try:
report = ReportManager.get_report_by_simulation(self.simulation_id)
if report and report.markdown_content:
# Limit report length to avoid excessive context
report_content = report.markdown_content[:15000]
if len(report.markdown_content) > 15000:
report_content += "\n\n... [Report content truncated] ..."
except Exception as e:
logger.warning(t("report.fetchReportFailed", error=e))
system_prompt = CHAT_SYSTEM_PROMPT_TEMPLATE.format(
simulation_requirement=self.simulation_requirement,
report_content=report_content
if report_content
else "(No report available yet)",
tools_description=self._get_tools_description(),
)
system_prompt = f"{system_prompt}\n\n{get_language_instruction()}"
# Build message
messages = [{"role": "system", "content": system_prompt}]
# Add conversation history
for h in chat_history[-10:]: # Limit history length
messages.append(h)
# Add user message
messages.append({"role": "user", "content": message})
# ReACT loop (simplified)
tool_calls_made = []
max_iterations = 2 # Reduce iterations
for iteration in range(max_iterations):
response = self.llm.chat(messages=messages, temperature=0.5)
# Parse tool calls
tool_calls = self._parse_tool_calls(response)
if not tool_calls:
# No tool calls, return response directly
clean_response = re.sub(
r"<tool_call>.*?</tool_call>", "", response, flags=re.DOTALL
)
clean_response = re.sub(r"\[TOOL_CALL\].*?\)", "", clean_response)
return {
"response": clean_response.strip(),
"tool_calls": tool_calls_made,
"sources": [
tc.get("parameters", {}).get("query", "")
for tc in tool_calls_made
],
}
# Execute tool calls (limit count)
tool_results = []
for call in tool_calls[:1]: # Max 1 tool call per round
if len(tool_calls_made) >= self.MAX_TOOL_CALLS_PER_CHAT:
break
result = self._execute_tool(call["name"], call.get("parameters", {}))
tool_results.append(
{
"tool": call["name"],
"result": result[:1500], # Limit result length
}
)
tool_calls_made.append(call)
# Add result to message
messages.append({"role": "assistant", "content": response})
observation = "\n".join(
[f"[{r['tool']} result]\n{r['result']}" for r in tool_results]
)
messages.append(
{"role": "user", "content": observation + CHAT_OBSERVATION_SUFFIX}
)
# Reached max iterations, get final response
final_response = self.llm.chat(messages=messages, temperature=0.5)
# Clean response
clean_response = re.sub(
r"<tool_call>.*?</tool_call>", "", final_response, flags=re.DOTALL
)
clean_response = re.sub(r"\[TOOL_CALL\].*?\)", "", clean_response)
return {
"response": clean_response.strip(),
"tool_calls": tool_calls_made,
"sources": [
tc.get("parameters", {}).get("query", "") for tc in tool_calls_made
],
}
class ReportManager:
"""
Report manager.
Responsible for report persistence and retrieval.
File structure (section-by-section output):
reports/
{report_id}/
meta.json - report metadata and status
outline.json - report outline
progress.json - generation progress
section_01.md - section 1
section_02.md - section 2
...
full_report.md - full report
"""
# Report storage directory
REPORTS_DIR = os.path.join(Config.UPLOAD_FOLDER, "reports")
@classmethod
def _ensure_reports_dir(cls):
"""Ensure the reports root directory exists."""
os.makedirs(cls.REPORTS_DIR, exist_ok=True)
@classmethod
def _get_report_folder(cls, report_id: str) -> str:
"""Return the report folder path."""
return os.path.join(cls.REPORTS_DIR, report_id)
@classmethod
def _ensure_report_folder(cls, report_id: str) -> str:
"""Ensure the report folder exists and return its path."""
folder = cls._get_report_folder(report_id)
os.makedirs(folder, exist_ok=True)
return folder
@classmethod
def _get_report_path(cls, report_id: str) -> str:
"""Return the report metadata file path."""
return os.path.join(cls._get_report_folder(report_id), "meta.json")
@classmethod
def _get_report_markdown_path(cls, report_id: str) -> str:
"""Return the full-report Markdown file path."""
return os.path.join(cls._get_report_folder(report_id), "full_report.md")
@classmethod
def _get_outline_path(cls, report_id: str) -> str:
"""Return the outline file path."""
return os.path.join(cls._get_report_folder(report_id), "outline.json")
@classmethod
def _get_progress_path(cls, report_id: str) -> str:
"""Return the progress file path."""
return os.path.join(cls._get_report_folder(report_id), "progress.json")
@classmethod
def _get_section_path(cls, report_id: str, section_index: int) -> str:
"""Return the section Markdown file path."""
return os.path.join(
cls._get_report_folder(report_id), f"section_{section_index:02d}.md"
)
@classmethod
def _get_agent_log_path(cls, report_id: str) -> str:
"""Return the agent log file path."""
return os.path.join(cls._get_report_folder(report_id), "agent_log.jsonl")
@classmethod
def _get_console_log_path(cls, report_id: str) -> str:
"""Return the console log file path."""
return os.path.join(cls._get_report_folder(report_id), "console_log.txt")
@classmethod
def get_console_log(cls, report_id: str, from_line: int = 0) -> Dict[str, Any]:
"""
Return the console log contents.
These are the console output logs (INFO, WARNING, etc.) produced
during report generation, distinct from the structured agent_log.jsonl.
Args:
report_id: Report ID.
from_line: Line number to start reading from (for incremental
retrieval; 0 means from the beginning).
Returns:
{
"logs": [list of log lines],
"total_lines": total number of lines,
"from_line": starting line number,
"has_more": whether more logs remain
}
"""
log_path = cls._get_console_log_path(report_id)
if not os.path.exists(log_path):
return {"logs": [], "total_lines": 0, "from_line": 0, "has_more": False}
logs = []
total_lines = 0
with open(log_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
total_lines = i + 1
if i >= from_line:
# Keep original log line, strip trailing newline
logs.append(line.rstrip("\n\r"))
return {
"logs": logs,
"total_lines": total_lines,
"from_line": from_line,
"has_more": False, # Already read to end
}
@classmethod
def get_console_log_stream(cls, report_id: str) -> List[str]:
"""
Return the full console log (retrieved all at once).
Args:
report_id: Report ID.
Returns:
List of log lines.
"""
result = cls.get_console_log(report_id, from_line=0)
return result["logs"]
@classmethod
def get_agent_log(cls, report_id: str, from_line: int = 0) -> Dict[str, Any]:
"""
Return the agent log contents.
Args:
report_id: Report ID.
from_line: Line number to start reading from (for incremental
retrieval; 0 means from the beginning).
Returns:
{
"logs": [list of log entries],
"total_lines": total number of lines,
"from_line": starting line number,
"has_more": whether more logs remain
}
"""
log_path = cls._get_agent_log_path(report_id)
if not os.path.exists(log_path):
return {"logs": [], "total_lines": 0, "from_line": 0, "has_more": False}
logs = []
total_lines = 0
with open(log_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
total_lines = i + 1
if i >= from_line:
try:
log_entry = json.loads(line.strip())
logs.append(log_entry)
except json.JSONDecodeError:
# Skip lines that failed to parse
continue
return {
"logs": logs,
"total_lines": total_lines,
"from_line": from_line,
"has_more": False, # Already read to end
}
@classmethod
def get_agent_log_stream(cls, report_id: str) -> List[Dict[str, Any]]:
"""
Return the full agent log (retrieved all at once).
Args:
report_id: Report ID.
Returns:
List of log entries.
"""
result = cls.get_agent_log(report_id, from_line=0)
return result["logs"]
@classmethod
def save_outline(cls, report_id: str, outline: ReportOutline) -> None:
"""
Save the report outline.
Called immediately after the planning stage completes.
"""
cls._ensure_report_folder(report_id)
with open(cls._get_outline_path(report_id), "w", encoding="utf-8") as f:
json.dump(outline.to_dict(), f, ensure_ascii=False, indent=2)
logger.info(t("report.outlineSaved", reportId=report_id))
@classmethod
def save_section(
cls, report_id: str, section_index: int, section: ReportSection
) -> str:
"""
Save a single section.
Called immediately after each section is generated, enabling
section-by-section output.
Args:
report_id: Report ID.
section_index: Section index (1-based).
section: Section object.
Returns:
The saved file path.
"""
cls._ensure_report_folder(report_id)
# Build section Markdown content - clean up possible duplicate titles
cleaned_content = cls._clean_section_content(section.content, section.title)
md_content = f"## {section.title}\n\n"
if cleaned_content:
md_content += f"{cleaned_content}\n\n"
# Save file
file_suffix = f"section_{section_index:02d}.md"
file_path = os.path.join(cls._get_report_folder(report_id), file_suffix)
with open(file_path, "w", encoding="utf-8") as f:
f.write(md_content)
logger.info(
t("report.sectionFileSaved", reportId=report_id, fileSuffix=file_suffix)
)
return file_path
@classmethod
def _clean_section_content(cls, content: str, section_title: str) -> str:
"""
Clean section content.
1. Remove Markdown heading lines at the start of the content that
duplicate the section title.
2. Convert all headings of level ### and below to bold text.
Args:
content: Original content.
section_title: Section title.
Returns:
The cleaned content.
"""
import re
if not content:
return content
content = content.strip()
lines = content.split("\n")
cleaned_lines = []
skip_next_empty = False
for i, line in enumerate(lines):
stripped = line.strip()
# Check if it's a Markdown heading line
heading_match = re.match(r"^(#{1,6})\s+(.+)$", stripped)
if heading_match:
level = len(heading_match.group(1))
title_text = heading_match.group(2).strip()
# Check if heading duplicates section title (skip duplicates within first 5 lines)
if i < 5:
if title_text == section_title or title_text.replace(
" ", ""
) == section_title.replace(" ", ""):
skip_next_empty = True
continue
# Convert all heading levels (#, ##, ###, ####, etc.) to bold
# Section titles are added by the system, content should not contain any headings
cleaned_lines.append(f"**{title_text}**")
cleaned_lines.append("") # Add blank line
continue
# If previous line was a skipped heading and current line is empty, skip it too
if skip_next_empty and stripped == "":
skip_next_empty = False
continue
skip_next_empty = False
cleaned_lines.append(line)
# Remove leading blank lines
while cleaned_lines and cleaned_lines[0].strip() == "":
cleaned_lines.pop(0)
# Remove leading separator lines
while cleaned_lines and cleaned_lines[0].strip() in ["---", "***", "___"]:
cleaned_lines.pop(0)
# Also remove blank line after separator
while cleaned_lines and cleaned_lines[0].strip() == "":
cleaned_lines.pop(0)
return "\n".join(cleaned_lines)
@classmethod
def update_progress(
cls,
report_id: str,
status: str,
progress: int,
message: str,
current_section: str = None,
completed_sections: List[str] = None,
) -> None:
"""
Update the report generation progress.
The frontend can read progress.json to obtain real-time progress.
"""
cls._ensure_report_folder(report_id)
progress_data = {
"status": status,
"progress": progress,
"message": message,
"current_section": current_section,
"completed_sections": completed_sections or [],
"updated_at": datetime.now().isoformat(),
}
with open(cls._get_progress_path(report_id), "w", encoding="utf-8") as f:
json.dump(progress_data, f, ensure_ascii=False, indent=2)
@classmethod
def get_progress(cls, report_id: str) -> Optional[Dict[str, Any]]:
"""Return the report generation progress."""
path = cls._get_progress_path(report_id)
if not os.path.exists(path):
return None
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
@classmethod
def get_generated_sections(cls, report_id: str) -> List[Dict[str, Any]]:
"""
Return the list of generated sections.
Returns information about all saved section files.
"""
folder = cls._get_report_folder(report_id)
if not os.path.exists(folder):
return []
sections = []
for filename in sorted(os.listdir(folder)):
if filename.startswith("section_") and filename.endswith(".md"):
file_path = os.path.join(folder, filename)
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# Parse section index from filename
parts = filename.replace(".md", "").split("_")
section_index = int(parts[1])
sections.append(
{
"filename": filename,
"section_index": section_index,
"content": content,
}
)
return sections
@classmethod
def assemble_full_report(cls, report_id: str, outline: ReportOutline) -> str:
"""
Assemble the full report.
Assemble the full report from the saved section files and clean up
the headings.
"""
folder = cls._get_report_folder(report_id)
# Build report header
md_content = f"# {outline.title}\n\n"
md_content += f"> {outline.summary}\n\n"
md_content += f"---\n\n"
# Read all section files in order
sections = cls.get_generated_sections(report_id)
for section_info in sections:
md_content += section_info["content"]
# Post-process: clean up heading issues in full report
md_content = cls._post_process_report(md_content, outline)
# Save full report
full_path = cls._get_report_markdown_path(report_id)
with open(full_path, "w", encoding="utf-8") as f:
f.write(md_content)
logger.info(t("report.fullReportAssembled", reportId=report_id))
return md_content
@classmethod
def _post_process_report(cls, content: str, outline: ReportOutline) -> str:
"""
Post-process report content.
1. Remove duplicate headings.
2. Keep the report main title (#) and section titles (##); remove
other heading levels (###, ####, etc.).
3. Clean up extra blank lines and divider lines.
Args:
content: Original report content.
outline: Report outline.
Returns:
The processed content.
"""
import re
lines = content.split("\n")
processed_lines = []
prev_was_heading = False
# Collect all section titles from outline
section_titles = set()
for section in outline.sections:
section_titles.add(section.title)
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
# Check if it's a heading line
heading_match = re.match(r"^(#{1,6})\s+(.+)$", stripped)
if heading_match:
level = len(heading_match.group(1))
title = heading_match.group(2).strip()
# Check if duplicate heading (same content within 5 consecutive lines)
is_duplicate = False
for j in range(max(0, len(processed_lines) - 5), len(processed_lines)):
prev_line = processed_lines[j].strip()
prev_match = re.match(r"^(#{1,6})\s+(.+)$", prev_line)
if prev_match:
prev_title = prev_match.group(2).strip()
if prev_title == title:
is_duplicate = True
break
if is_duplicate:
# Skip duplicate heading and following blank line
i += 1
while i < len(lines) and lines[i].strip() == "":
i += 1
continue
# Heading level handling:
# - # (level=1) only keep report main title
# - ## (level=2) keep section titles
# - ### and below (level>=3) convert to bold text
if level == 1:
if title == outline.title:
# Keep report main title
processed_lines.append(line)
prev_was_heading = True
elif title in section_titles:
# Section title incorrectly used #, fix to ##
processed_lines.append(f"## {title}")
prev_was_heading = True
else:
# Other level-1 headings converted to bold
processed_lines.append(f"**{title}**")
processed_lines.append("")
prev_was_heading = False
elif level == 2:
if title in section_titles or title == outline.title:
# Keep section title
processed_lines.append(line)
prev_was_heading = True
else:
# Non-section level-2 headings converted to bold
processed_lines.append(f"**{title}**")
processed_lines.append("")
prev_was_heading = False
else:
# ### and below headings converted to bold text
processed_lines.append(f"**{title}**")
processed_lines.append("")
prev_was_heading = False
i += 1
continue
elif stripped == "---" and prev_was_heading:
# Skip separator line immediately after heading
i += 1
continue
elif stripped == "" and prev_was_heading:
# Keep only one blank line after heading
if processed_lines and processed_lines[-1].strip() != "":
processed_lines.append(line)
prev_was_heading = False
else:
processed_lines.append(line)
prev_was_heading = False
i += 1
# Clean up consecutive blank lines (keep max 2)
result_lines = []
empty_count = 0
for line in processed_lines:
if line.strip() == "":
empty_count += 1
if empty_count <= 2:
result_lines.append(line)
else:
empty_count = 0
result_lines.append(line)
return "\n".join(result_lines)
@classmethod
def save_report(cls, report: Report) -> None:
"""Save report metadata and the full report."""
cls._ensure_report_folder(report.report_id)
# Save meta info JSON
with open(cls._get_report_path(report.report_id), "w", encoding="utf-8") as f:
json.dump(report.to_dict(), f, ensure_ascii=False, indent=2)
# Save outline
if report.outline:
cls.save_outline(report.report_id, report.outline)
# Save full Markdown report
if report.markdown_content:
with open(
cls._get_report_markdown_path(report.report_id), "w", encoding="utf-8"
) as f:
f.write(report.markdown_content)
logger.info(t("report.reportSaved", reportId=report.report_id))
@classmethod
def get_report(cls, report_id: str) -> Optional[Report]:
"""Return the report."""
path = cls._get_report_path(report_id)
if not os.path.exists(path):
# Backward compat: check files stored directly in reports directory
old_path = os.path.join(cls.REPORTS_DIR, f"{report_id}.json")
if os.path.exists(old_path):
path = old_path
else:
return None
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
# Rebuild Report object
outline = None
if data.get("outline"):
outline_data = data["outline"]
sections = []
for s in outline_data.get("sections", []):
sections.append(
ReportSection(title=s["title"], content=s.get("content", ""))
)
outline = ReportOutline(
title=outline_data["title"],
summary=outline_data["summary"],
sections=sections,
)
# If markdown_content is empty, try reading from full_report.md
markdown_content = data.get("markdown_content", "")
if not markdown_content:
full_report_path = cls._get_report_markdown_path(report_id)
if os.path.exists(full_report_path):
with open(full_report_path, "r", encoding="utf-8") as f:
markdown_content = f.read()
return Report(
report_id=data["report_id"],
simulation_id=data["simulation_id"],
graph_id=data["graph_id"],
simulation_requirement=data["simulation_requirement"],
status=ReportStatus(data["status"]),
outline=outline,
markdown_content=markdown_content,
created_at=data.get("created_at", ""),
completed_at=data.get("completed_at", ""),
error=data.get("error"),
)
@classmethod
def get_report_by_simulation(cls, simulation_id: str) -> Optional[Report]:
"""Return the report for the given simulation ID."""
cls._ensure_reports_dir()
for item in os.listdir(cls.REPORTS_DIR):
item_path = os.path.join(cls.REPORTS_DIR, item)
# New format: folder
if os.path.isdir(item_path):
report = cls.get_report(item)
if report and report.simulation_id == simulation_id:
return report
# Backward compat: JSON file
elif item.endswith(".json"):
report_id = item[:-5]
report = cls.get_report(report_id)
if report and report.simulation_id == simulation_id:
return report
return None
@classmethod
def list_reports(
cls, simulation_id: Optional[str] = None, limit: int = 50
) -> List[Report]:
"""List reports."""
cls._ensure_reports_dir()
reports = []
for item in os.listdir(cls.REPORTS_DIR):
item_path = os.path.join(cls.REPORTS_DIR, item)
# New format: folder
if os.path.isdir(item_path):
report = cls.get_report(item)
if report:
if simulation_id is None or report.simulation_id == simulation_id:
reports.append(report)
# Backward compat: JSON file
elif item.endswith(".json"):
report_id = item[:-5]
report = cls.get_report(report_id)
if report:
if simulation_id is None or report.simulation_id == simulation_id:
reports.append(report)
# Sort by creation time descending
reports.sort(key=lambda r: r.created_at, reverse=True)
return reports[:limit]
@classmethod
def delete_report(cls, report_id: str) -> bool:
"""Delete a report (the whole folder)."""
import shutil
folder_path = cls._get_report_folder(report_id)
# New format: delete entire folder
if os.path.exists(folder_path) and os.path.isdir(folder_path):
shutil.rmtree(folder_path)
logger.info(t("report.reportFolderDeleted", reportId=report_id))
return True
# Backward compat: delete individual file
deleted = False
old_json_path = os.path.join(cls.REPORTS_DIR, f"{report_id}.json")
old_md_path = os.path.join(cls.REPORTS_DIR, f"{report_id}.md")
if os.path.exists(old_json_path):
os.remove(old_json_path)
deleted = True
if os.path.exists(old_md_path):
os.remove(old_md_path)
deleted = True
return deleted