2575 lines
101 KiB
Python
2575 lines
101 KiB
Python
"""
|
||
Report Agent service
|
||
Uses LangChain + Zep to implement the ReACT pattern for simulation report generation
|
||
|
||
Features:
|
||
1. Generate reports based on simulation requirements and Zep graph information
|
||
2. First plan the table of contents, then generate section by section
|
||
3. Each section uses the ReACT multi-round thinking and reflection pattern
|
||
4. Support conversations with the user, autonomously calling 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 .zep_tools import (
|
||
ZepToolsService,
|
||
SearchResult,
|
||
InsightForgeResult,
|
||
PanoramaResult,
|
||
InterviewResult
|
||
)
|
||
|
||
logger = get_logger('mirofish.report_agent')
|
||
|
||
|
||
class ReportLogger:
|
||
"""
|
||
Detailed logger for Report Agent
|
||
|
||
Generates an agent_log.jsonl file in the report folder, recording every detailed action.
|
||
Each line is a complete JSON object containing timestamps, action types, 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:
|
||
"""Get the 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 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 dictionary, not truncated
|
||
section_title: Current section title (optional)
|
||
section_index: Current section index (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 the completion of outline planning"""
|
||
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 the ReACT thinking process"""
|
||
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 the 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, not truncated
|
||
"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 the 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, not truncated
|
||
"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 the completion of section content generation (only records content, not the whole section)"""
|
||
self.log(
|
||
action="section_content",
|
||
stage="generating",
|
||
section_title=section_title,
|
||
section_index=section_index,
|
||
details={
|
||
"content": content, # Full content, not truncated
|
||
"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 the completion of section generation
|
||
|
||
The frontend should listen to this log to determine whether a section is truly complete
|
||
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 the completion of report generation"""
|
||
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-style logger for Report Agent
|
||
|
||
Writes console-style logs (INFO, WARNING, etc.) to the console_log.txt file in the report folder.
|
||
These logs are different from agent_log.jsonl and are plain-text console output.
|
||
"""
|
||
|
||
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 log file directory 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 to also write logs to a file"""
|
||
import logging
|
||
|
||
# Create the file handler
|
||
self._file_handler = logging.FileHandler(
|
||
self.log_file_path,
|
||
mode='a',
|
||
encoding='utf-8'
|
||
)
|
||
self._file_handler.setLevel(logging.INFO)
|
||
|
||
# Use the same concise format as the console
|
||
formatter = logging.Formatter(
|
||
'[%(asctime)s] %(levelname)s: %(message)s',
|
||
datefmt='%H:%M:%S'
|
||
)
|
||
self._file_handler.setFormatter(formatter)
|
||
|
||
# Attach to report_agent-related loggers
|
||
loggers_to_attach = [
|
||
'mirofish.report_agent',
|
||
'mirofish.zep_tools',
|
||
]
|
||
|
||
for logger_name in loggers_to_attach:
|
||
target_logger = logging.getLogger(logger_name)
|
||
# Avoid adding duplicates
|
||
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 logger"""
|
||
import logging
|
||
|
||
if self._file_handler:
|
||
loggers_to_detach = [
|
||
'mirofish.report_agent',
|
||
'mirofish.zep_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:
|
||
"""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:
|
||
"""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 in-depth analysis. It will:
|
||
1. Automatically decompose your question into multiple sub-questions
|
||
2. Retrieve information from the simulation graph across multiple dimensions
|
||
3. Integrate the results of semantic search, entity analysis, and relationship chain tracing
|
||
4. Return the most comprehensive and deepest retrieval content
|
||
|
||
[Usage Scenarios]
|
||
- When you need in-depth analysis of a specific topic
|
||
- When you need to understand multiple aspects of an event
|
||
- When you need rich material to support a report section
|
||
|
||
[Returned Content]
|
||
- Original text of relevant facts (can be quoted directly)
|
||
- Core entity insights
|
||
- Relationship chain analysis"""
|
||
|
||
TOOL_DESC_PANORAMA_SEARCH = """\
|
||
[Broad Search - Get the Big Picture]
|
||
This tool is used to obtain the complete picture of simulation results and is especially suited for understanding how an event evolved. It will:
|
||
1. Fetch all relevant nodes and relationships
|
||
2. Distinguish currently valid facts from historical/expired ones
|
||
3. Help you understand how public opinion evolved
|
||
|
||
[Usage Scenarios]
|
||
- When you need the full development arc of an event
|
||
- When you need to compare public opinion changes across phases
|
||
- When you need comprehensive entity and relationship information
|
||
|
||
[Returned Content]
|
||
- Currently valid facts (latest simulation results)
|
||
- Historical/expired facts (records of evolution)
|
||
- All entities involved"""
|
||
|
||
TOOL_DESC_QUICK_SEARCH = """\
|
||
[Simple Search - Quick Retrieval]
|
||
A lightweight, fast retrieval tool suitable for simple, direct information lookups.
|
||
|
||
[Usage Scenarios]
|
||
- When you need to quickly find a specific piece of information
|
||
- When you need to verify a fact
|
||
- Simple information retrieval
|
||
|
||
[Returned Content]
|
||
- List of facts most relevant to the query"""
|
||
|
||
TOOL_DESC_INTERVIEW_AGENTS = """\
|
||
[Deep Interview - Real Agent Interviews (Dual Platform)]
|
||
Calls the OASIS simulation environment's interview API to conduct real interviews with the running simulation Agents!
|
||
This is not LLM simulation — it calls real interview endpoints to get raw answers from the simulation Agents.
|
||
By default it interviews on both Twitter and Reddit simultaneously, yielding a more comprehensive set of viewpoints.
|
||
|
||
Workflow:
|
||
1. Automatically read the persona files to learn about all simulation Agents
|
||
2. Intelligently select the Agents most relevant to the interview topic (e.g. students, media, officials)
|
||
3. Automatically generate interview questions
|
||
4. Call the /api/simulation/interview/batch endpoint to perform real interviews on both platforms
|
||
5. Integrate all interview results and provide multi-perspective analysis
|
||
|
||
[Usage Scenarios]
|
||
- When you need to understand different roles' views of the event (How do students see it? How does the media see it? What do officials say?)
|
||
- When you need to collect opinions and positions from multiple sides
|
||
- When you need real answers from simulation Agents (from the OASIS simulation environment)
|
||
- When you want the report to be more vivid, including "interview transcripts"
|
||
|
||
[Returned Content]
|
||
- Identity information of interviewed Agents
|
||
- Interview answers from each Agent on both Twitter and Reddit
|
||
- Key quotes (can be cited directly)
|
||
- Interview summary and comparison of viewpoints
|
||
|
||
[Important] This feature requires the OASIS simulation environment to be running!"""
|
||
|
||
# ── Outline planning prompt ──
|
||
|
||
PLAN_SYSTEM_PROMPT = """\
|
||
You are a "Future Prediction Report" writing expert with a "God's-eye view" of the simulation world — you can observe the behavior, speech, and interactions of every Agent in the simulation.
|
||
|
||
[Core Philosophy]
|
||
We build a simulation world and inject a specific "simulation requirement" as a variable. The evolution of the simulation world is a prediction of what could happen in the future. What you are observing is not "experimental data", but a "rehearsal 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 do various Agents (crowds) react and act?
|
||
3. What future trends and risks does this simulation reveal that deserve attention?
|
||
|
||
[Report Positioning]
|
||
- ✅ This is a future prediction report based on a simulation, revealing "if this happens, the future will look like this"
|
||
- ✅ Focus on the predicted outcomes: event trajectories, group reactions, emergent phenomena, potential risks
|
||
- ✅ Agents' words and actions in the simulation world are predictions of future crowd behavior
|
||
- ❌ It is NOT an analysis of the current state of the real world
|
||
- ❌ It is NOT a generic overview of public opinion
|
||
|
||
[Section Count Limit]
|
||
- Minimum 2 sections, maximum 5 sections
|
||
- No sub-sections needed; each section should be written as complete content
|
||
- Keep content focused and refined, focusing on the core prediction findings
|
||
- The section structure is up to you, based on the prediction results
|
||
|
||
Please output the report outline in JSON format as follows:
|
||
{
|
||
"title": "Report title",
|
||
"summary": "Report summary (one-sentence summary of the core prediction finding)",
|
||
"sections": [
|
||
{
|
||
"title": "Section title",
|
||
"description": "Description of the section content"
|
||
}
|
||
]
|
||
}
|
||
|
||
Note: the sections array must contain at least 2 and at most 5 elements!"""
|
||
|
||
PLAN_USER_PROMPT_TEMPLATE = """\
|
||
[Prediction Scenario Settings]
|
||
The variable (simulation requirement) we injected into the simulation world: {simulation_requirement}
|
||
|
||
[Simulation World Scale]
|
||
- Number of entities participating in the simulation: {total_nodes}
|
||
- Number of relationships generated between entities: {total_edges}
|
||
- Distribution of entity types: {entity_types}
|
||
- Number of active Agents: {total_entities}
|
||
|
||
[Sample of Future Facts Predicted by the Simulation]
|
||
{related_facts_json}
|
||
|
||
Please review this future rehearsal with a "God's-eye view":
|
||
1. Under the conditions we set, what state did the future take on?
|
||
2. How did various groups of people (Agents) react and act?
|
||
3. What future trends does this simulation reveal that deserve attention?
|
||
|
||
Based on the prediction results, design the most appropriate report section structure.
|
||
|
||
[Reminder] Report section count: minimum 2, maximum 5, with content focused and refined on the core prediction findings."""
|
||
|
||
# ── Section generation prompt ──
|
||
|
||
SECTION_SYSTEM_PROMPT_TEMPLATE = """\
|
||
You are a "Future Prediction Report" writing expert, currently writing a section of the report.
|
||
|
||
Report title: {report_title}
|
||
Report summary: {report_summary}
|
||
Prediction scenario (simulation requirement): {simulation_requirement}
|
||
|
||
The section you are writing right now: {section_title}
|
||
|
||
═══════════════════════════════════════════════════════════════
|
||
[Core Philosophy]
|
||
═══════════════════════════════════════════════════════════════
|
||
|
||
The simulation world is a rehearsal of the future. We inject specific conditions (the simulation requirement) into the simulation world, and the behavior and interactions of Agents in the simulation are predictions of future crowd behavior.
|
||
|
||
Your task is to:
|
||
- Reveal what happened in the future under the set conditions
|
||
- Predict how various groups of people (Agents) react and act
|
||
- Identify future trends, risks, and opportunities that deserve attention
|
||
|
||
❌ Do not write this as an analysis of the current state of the real world
|
||
✅ Focus on "what the future will look like" — the simulation results are the predicted future
|
||
|
||
═══════════════════════════════════════════════════════════════
|
||
[Most Important Rules - Must Be Followed]
|
||
═══════════════════════════════════════════════════════════════
|
||
|
||
1. [You MUST call tools to observe the simulation world]
|
||
- You are observing the future rehearsal with a "God's-eye view"
|
||
- All content must come from events and Agent speech/actions that occurred in the simulation world
|
||
- It is forbidden to use your own knowledge to write the report content
|
||
- Each section must call tools at least 3 times (and at most 5 times) to observe the simulation world, which represents the future
|
||
|
||
2. [You MUST quote the Agents' original words and actions]
|
||
- Agents' speech and behavior are predictions of future crowd behavior
|
||
- Use the quote format in the report to display these predictions, for example:
|
||
> "A certain group of people would say: original text..."
|
||
- 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 include expressions in a language different from the report language
|
||
- The report must be written entirely in the language specified by the user
|
||
- When quoting content returned by tools in other languages, you must translate it into the report language before writing it in
|
||
- Preserve the original meaning during translation, and ensure the expression is natural and fluent
|
||
- This rule applies to both the body text and the content in quote blocks (> format)
|
||
|
||
4. [Faithfully present the prediction results]
|
||
- The report content must reflect the simulation results in the simulation world that represent the future
|
||
- Do not add information that does not exist in the simulation
|
||
- If information is insufficient in some aspect, state this honestly
|
||
|
||
═══════════════════════════════════════════════════════════════
|
||
[⚠️ Format Specification - Extremely Important!]
|
||
═══════════════════════════════════════════════════════════════
|
||
|
||
[One section = the smallest content unit]
|
||
- Each section is the smallest chunked unit of the report
|
||
- ❌ Do NOT use any Markdown headings (#, ##, ###, ####, etc.) inside a section
|
||
- ❌ Do NOT add the section's main title at the beginning of the content
|
||
- ✅ The section title is added automatically by the system; you only need to write plain body content
|
||
- ✅ Use **bold**, paragraph breaks, quotes, and lists to organize content, but do not use headings
|
||
|
||
[Correct Example]
|
||
```
|
||
This section analyzes the spread of public opinion around the event. Through in-depth analysis of the simulation data, we found...
|
||
|
||
**Outbreak Phase**
|
||
|
||
Weibo served as the on-site first mover for public opinion, playing the core role of breaking the news first:
|
||
|
||
> "Weibo contributed 68% of the first-wave volume..."
|
||
|
||
**Amplification Phase**
|
||
|
||
Douyin further amplified the event's impact:
|
||
|
||
- Strong visual impact
|
||
- High emotional resonance
|
||
```
|
||
|
||
[Incorrect Example]
|
||
```
|
||
## Executive Summary ← Wrong! Do not add any headings
|
||
### 1. Outbreak Phase ← Wrong! Do not use ### to split subsections
|
||
#### 1.1 Detailed Analysis ← Wrong! Do not use #### for further subdivision
|
||
|
||
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: in-depth insight analysis that automatically decomposes questions and retrieves facts and relationships across multiple dimensions
|
||
- panorama_search: broad panoramic search to understand the full picture, timeline, and evolution of an event
|
||
- quick_search: quickly verify a specific information point
|
||
- interview_agents: interview simulation Agents to obtain first-person perspectives and real reactions from different roles
|
||
|
||
═══════════════════════════════════════════════════════════════
|
||
[Workflow]
|
||
═══════════════════════════════════════════════════════════════
|
||
|
||
Each turn you may only do one of the following two things (never both):
|
||
|
||
Option A — Call a tool:
|
||
Output your thinking, then call one tool in the following format:
|
||
<tool_call>
|
||
{{"name": "tool name", "parameters": {{"param name": "param value"}}}}
|
||
</tool_call>
|
||
The system will execute the tool and return the result to you. You do not need to and must not write the tool's return result yourself.
|
||
|
||
Option B — Output the final content:
|
||
When you have gathered enough information from the tools, output the section content starting with "Final Answer:".
|
||
|
||
⚠️ Strictly forbidden:
|
||
- It is forbidden to include both a tool call and Final Answer in the same turn
|
||
- It is forbidden to fabricate tool return results (Observation) yourself; all tool results are injected by the system
|
||
- A maximum of one tool call per turn
|
||
|
||
═══════════════════════════════════════════════════════════════
|
||
[Section Content Requirements]
|
||
═══════════════════════════════════════════════════════════════
|
||
|
||
1. Content must be based on the simulation data retrieved by tools
|
||
2. Heavily quote original text to demonstrate the simulation's effects
|
||
3. Use Markdown format (but do not use headings):
|
||
- Use **bold text** to mark emphasis (replacing sub-headings)
|
||
- Use lists (- or 1.2.3.) to organize key points
|
||
- Use blank lines to separate different paragraphs
|
||
- ❌ Do not use any heading syntax such as #, ##, ###, ####
|
||
4. [Quote format specification — must stand as its own paragraph]
|
||
Quotes must stand alone as a paragraph, with a blank line before and after, and must not be mixed into a paragraph:
|
||
|
||
✅ Correct format:
|
||
```
|
||
The school's response was considered to lack substantive content.
|
||
|
||
> "The school's response pattern appears rigid and slow in the rapidly changing social media environment."
|
||
|
||
This assessment reflects the public's general dissatisfaction.
|
||
```
|
||
|
||
❌ Incorrect format:
|
||
```
|
||
The school's response was considered to lack substantive content.> "The school's response pattern..." This assessment reflects...
|
||
```
|
||
5. Maintain logical consistency with other sections
|
||
6. [Avoid repetition] Carefully read the completed section content below and do not repeat the same information
|
||
7. [Re-emphasizing] Do not add any headings! Use **bold** in place of sub-headings"""
|
||
|
||
SECTION_USER_PROMPT_TEMPLATE = """\
|
||
Completed section content (please read carefully and avoid repetition):
|
||
{previous_content}
|
||
|
||
═══════════════════════════════════════════════════════════════
|
||
[Current Task] Write the section: {section_title}
|
||
═══════════════════════════════════════════════════════════════
|
||
|
||
[Important Reminders]
|
||
1. Carefully read the completed sections above and avoid repeating the same content!
|
||
2. You must call tools to obtain simulation data before starting
|
||
3. Please mix different tools, do not use only one
|
||
4. Report content must come from the retrieval results, not from your own knowledge
|
||
|
||
[⚠️ Format Warning - Must Be Followed]
|
||
- ❌ Do not write any headings (no #, ##, ###, ####)
|
||
- ❌ Do not write "{section_title}" as the opening
|
||
- ✅ Section titles are added automatically by the system
|
||
- ✅ Write the body directly, use **bold** in place of sub-headings
|
||
|
||
Please start:
|
||
1. First think (Thought) about what information this section needs
|
||
2. Then call a tool (Action) to obtain simulation data
|
||
3. Once enough information is collected, output the Final Answer (plain body text, no headings of any kind)"""
|
||
|
||
# ── Message templates inside the ReACT loop ──
|
||
|
||
REACT_OBSERVATION_TEMPLATE = """\
|
||
Observation (retrieval result):
|
||
|
||
═══ Tool {tool_name} returned ═══
|
||
{result}
|
||
|
||
═══════════════════════════════════════════════════════════════
|
||
Tool has been called {tool_calls_count}/{max_tool_calls} times (used: {used_tools_str}){unused_hint}
|
||
- If information is sufficient: start output with "Final Answer:" (must quote the original text above)
|
||
- If more information is needed: call one tool to continue retrieval
|
||
═══════════════════════════════════════════════════════════════"""
|
||
|
||
REACT_INSUFFICIENT_TOOLS_MSG = (
|
||
"[Notice] You have only called the tool {tool_calls_count} times; at least {min_tool_calls} are required."
|
||
" Please call tools to obtain more simulation data before outputting Final Answer. {unused_hint}"
|
||
)
|
||
|
||
REACT_INSUFFICIENT_TOOLS_MSG_ALT = (
|
||
"You have only called tools {tool_calls_count} times; at least {min_tool_calls} are required."
|
||
" Please call tools to obtain simulation data. {unused_hint}"
|
||
)
|
||
|
||
REACT_TOOL_LIMIT_MSG = (
|
||
"The tool call limit has been reached ({tool_calls_count}/{max_tool_calls}); no more tool calls are allowed."
|
||
' Please immediately output the section content starting with "Final Answer:" based on the information you have already obtained.'
|
||
)
|
||
|
||
REACT_UNUSED_TOOLS_HINT = "\n💡 You haven't used yet: {unused_list}; it is recommended to try different tools to get information from multiple angles"
|
||
|
||
REACT_FORCE_FINAL_MSG = "The tool call limit has been reached; please directly output Final Answer: 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, avoiding lengthy deliberation
|
||
3. Only call tools to retrieve more data when the report content is insufficient
|
||
4. Answers should be concise, clear, and well-organized
|
||
|
||
[Available Tools] (use only when needed, call at most 1-2 times)
|
||
{tools_description}
|
||
|
||
[Tool Call Format]
|
||
<tool_call>
|
||
{{"name": "tool name", "parameters": {{"param name": "param value"}}}}
|
||
</tool_call>
|
||
|
||
[Answer Style]
|
||
- Be concise and direct; no long-winded essays
|
||
- Use the > format to quote key content
|
||
- Give the conclusion first, then explain the reasons"""
|
||
|
||
CHAT_OBSERVATION_SUFFIX = "\n\nPlease answer the question concisely."
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# ReportAgent main class
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
|
||
class ReportAgent:
|
||
"""
|
||
Report Agent - Simulation Report Generation Agent
|
||
|
||
Adopts the ReACT (Reasoning + Acting) pattern:
|
||
1. Planning phase: analyze simulation requirements and plan the report structure
|
||
2. Generation phase: generate content section by section, with multiple tool calls per section
|
||
3. Reflection phase: check content completeness and accuracy
|
||
"""
|
||
|
||
# Maximum number of tool calls (per section)
|
||
MAX_TOOL_CALLS_PER_SECTION = 5
|
||
|
||
# Maximum reflection rounds
|
||
MAX_REFLECTION_ROUNDS = 3
|
||
|
||
# Maximum number of tool calls in a conversation
|
||
MAX_TOOL_CALLS_PER_CHAT = 2
|
||
|
||
def __init__(
|
||
self,
|
||
graph_id: str,
|
||
simulation_id: str,
|
||
simulation_requirement: str,
|
||
llm_client: Optional[LLMClient] = None,
|
||
zep_tools: Optional[ZepToolsService] = 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)
|
||
zep_tools: Zep 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.zep_tools = zep_tools or ZepToolsService()
|
||
|
||
# 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 analyze in depth",
|
||
"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. 'Learn 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 (in 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.zep_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 the big 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.zep_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 - quick retrieval
|
||
query = parameters.get("query", "")
|
||
limit = parameters.get("limit", 10)
|
||
if isinstance(limit, str):
|
||
limit = int(limit)
|
||
result = self.zep_tools.quick_search(
|
||
graph_id=self.graph_id,
|
||
query=query,
|
||
limit=limit
|
||
)
|
||
return result.to_text()
|
||
|
||
elif tool_name == "interview_agents":
|
||
# Deep interview - call the real OASIS interview API to obtain simulation 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.zep_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.zep_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.zep_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 because it is 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.zep_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"Unknown tool: {tool_name}. Please use one of the following tools: insight_forge, panorama_search, quick_search"
|
||
|
||
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, used for validation during bare-JSON fallback parsing
|
||
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_call>{"name": "tool_name", "parameters": {...}}</tool_call>
|
||
2. Bare JSON (the entire 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 directly outputs bare JSON (without <tool_call> tags)
|
||
# Only tried when Format 1 did not match, to avoid false matches of 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
|
||
|
||
# The response may contain thinking text + bare JSON; try extracting the 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:
|
||
# Unify the 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)
|
||
|
||
def plan_outline(
|
||
self,
|
||
progress_callback: Optional[Callable] = None
|
||
) -> ReportOutline:
|
||
"""
|
||
Plan the report outline
|
||
|
||
Use the LLM to analyze simulation requirements and plan the report's table of contents.
|
||
|
||
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, obtain the simulation context
|
||
context = self.zep_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 the outline
|
||
sections = []
|
||
for section_data in response.get("sections", []):
|
||
sections.append(ReportSection(
|
||
title=section_data.get("title", ""),
|
||
content=""
|
||
))
|
||
|
||
outline = ReportOutline(
|
||
title=response.get("title", "Simulation Analysis Report"),
|
||
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 the default outline (3 sections, as a fallback)
|
||
return ReportOutline(
|
||
title="Future Prediction Report",
|
||
summary="Future trend and risk analysis based on simulation predictions",
|
||
sections=[
|
||
ReportSection(title="Prediction Scenario and Core Findings"),
|
||
ReportSection(title="Crowd Behavior Prediction Analysis"),
|
||
ReportSection(title="Trend Outlook and Risk Warning")
|
||
]
|
||
)
|
||
|
||
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 tools to obtain information
|
||
3. Observation - analyze the tool's returned results
|
||
4. Repeat until enough information is gathered or the maximum number of calls is reached
|
||
5. Final Answer - generate the section content
|
||
|
||
Args:
|
||
section: The section to generate
|
||
outline: The full outline
|
||
previous_sections: Content of previous sections (used to maintain consistency)
|
||
progress_callback: Progress callback
|
||
section_index: Section index (used for logging)
|
||
|
||
Returns:
|
||
The section content (in Markdown format)
|
||
"""
|
||
logger.info(t('report.reactGenerateSection', title=section.title))
|
||
|
||
# Record the section start log
|
||
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 the user prompt - up to 4000 chars per completed section are passed in
|
||
if previous_sections:
|
||
previous_parts = []
|
||
for sec in previous_sections:
|
||
# Up to 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 section)"
|
||
|
||
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 # Maximum number of iterations
|
||
min_tool_calls = 3 # Minimum number of tool calls
|
||
conflict_retries = 0 # Number of consecutive conflicts where tool calls and Final Answer appear together
|
||
used_tools = set() # Records the names of tools that have been called
|
||
all_tools = {"insight_forge", "panorama_search", "quick_search", "interview_agents"}
|
||
|
||
# Report context, used for InsightForge's sub-question 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 the LLM
|
||
response = self.llm.chat(
|
||
messages=messages,
|
||
temperature=0.5,
|
||
max_tokens=4096
|
||
)
|
||
|
||
# Check whether the LLM response is None (API exception or empty content)
|
||
if response is None:
|
||
logger.warning(t('report.sectionIterNone', title=section.title, iteration=iteration + 1))
|
||
# If iterations remain, add a message and retry
|
||
if iteration < max_iterations - 1:
|
||
messages.append({"role": "assistant", "content": "(response is empty)"})
|
||
messages.append({"role": "user", "content": "Please continue generating content."})
|
||
continue
|
||
# The last iteration also returned None; break out of the loop into forced finalization
|
||
break
|
||
|
||
logger.debug(f"LLM response: {response[:200]}...")
|
||
|
||
# Parse once and reuse the 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 a 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 attempts: discard this response and ask the LLM to reply again
|
||
messages.append({"role": "assistant", "content": response})
|
||
messages.append({
|
||
"role": "user",
|
||
"content": (
|
||
"[Format Error] You included both a tool call and Final Answer in a single reply, which is not allowed.\n"
|
||
"Each reply can only do one of the following two things:\n"
|
||
"- Call a tool (output one <tool_call> block, do not write Final Answer)\n"
|
||
"- Output the final content (start with 'Final Answer:', do not include <tool_call>)\n"
|
||
"Please reply again and do only one of them."
|
||
),
|
||
})
|
||
continue
|
||
else:
|
||
# Third attempt: graceful degradation, truncate to the first tool call and force execution
|
||
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 the 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:
|
||
# Tool call count is insufficient; reject and require more tool calls
|
||
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; we recommend giving them a try: {', '.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 end
|
||
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 attempted to call a tool ──
|
||
if has_tool_calls:
|
||
# Tool quota exhausted → state explicitly and require 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 the 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 the 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 a tool call nor a Final Answer ──
|
||
messages.append({"role": "assistant", "content": response})
|
||
|
||
if tool_calls_count < min_tool_calls:
|
||
# Tool call count insufficient; recommend unused tools
|
||
unused_tools = all_tools - used_tools
|
||
unused_hint = f"(These tools have not been used yet; we recommend giving them a try: {', '.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 call count is sufficient; the LLM output content but did not include "Final Answer:" prefix
|
||
# Use this content as the final answer directly, no more spinning
|
||
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
|
||
|
||
# Maximum iterations reached; 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=4096
|
||
)
|
||
|
||
# Check whether the LLM returned None during forced finalization
|
||
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
|
||
|
||
# Record the section content generation complete log
|
||
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 complete report (streaming output section by section)
|
||
|
||
Each section is saved to the folder as soon as it is generated, without waiting
|
||
for the entire report to finish.
|
||
|
||
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
|
||
|
||
# If no report_id is provided, auto-generate one
|
||
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()
|
||
)
|
||
|
||
# List of completed section titles (used for progress tracking)
|
||
completed_section_titles = []
|
||
|
||
try:
|
||
# Initialization: create the report folder and save the initial state
|
||
ReportManager._ensure_report_folder(report_id)
|
||
|
||
# Initialize the structured logger (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 the 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 the outline
|
||
report.status = ReportStatus.PLANNING
|
||
ReportManager.update_progress(
|
||
report_id, "planning", 5, t('progress.startPlanningOutline'),
|
||
completed_sections=[]
|
||
)
|
||
|
||
# Log the start of planning
|
||
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 the completion of planning
|
||
self.report_logger.log_planning_complete(outline.to_dict())
|
||
|
||
# Save the outline to a 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 (saving per section)
|
||
report.status = ReportStatus.GENERATING
|
||
|
||
total_sections = len(outline.sections)
|
||
generated_sections = [] # Saved 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 the 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 the 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 the 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 the 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 the 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 the 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 the error
|
||
if self.report_logger:
|
||
self.report_logger.log_error(str(e), "failed")
|
||
|
||
# Save the 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 errors when saving the failure state
|
||
|
||
# Close the 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]:
|
||
"""
|
||
Converse with the Report Agent
|
||
|
||
During the conversation, the Agent can autonomously call retrieval tools to answer questions.
|
||
|
||
Args:
|
||
message: User message
|
||
chat_history: Conversation history
|
||
|
||
Returns:
|
||
{
|
||
"response": "Agent's reply",
|
||
"tool_calls": [list of tools called],
|
||
"sources": [information sources]
|
||
}
|
||
"""
|
||
logger.info(t('report.agentChat', message=message[:50]))
|
||
|
||
chat_history = chat_history or []
|
||
|
||
# Get the content of the generated report
|
||
report_content = ""
|
||
try:
|
||
report = ReportManager.get_report_by_simulation(self.simulation_id)
|
||
if report and report.markdown_content:
|
||
# Limit the report length to avoid overly long 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 yet)",
|
||
tools_description=self._get_tools_description(),
|
||
)
|
||
system_prompt = f"{system_prompt}\n\n{get_language_instruction()}"
|
||
|
||
# Build the messages
|
||
messages = [{"role": "system", "content": system_prompt}]
|
||
|
||
# Add the conversation history
|
||
for h in chat_history[-10:]: # Limit the history length
|
||
messages.append(h)
|
||
|
||
# Add the user message
|
||
messages.append({
|
||
"role": "user",
|
||
"content": message
|
||
})
|
||
|
||
# ReACT loop (simplified)
|
||
tool_calls_made = []
|
||
max_iterations = 2 # Reduced number of 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 the 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 (limited)
|
||
tool_results = []
|
||
for call in tool_calls[:1]: # At most 1 tool call per turn
|
||
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 the result length
|
||
})
|
||
tool_calls_made.append(call)
|
||
|
||
# Append the result to messages
|
||
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
|
||
})
|
||
|
||
# Maximum iterations reached; obtain the final response
|
||
final_response = self.llm.chat(
|
||
messages=messages,
|
||
temperature=0.5
|
||
)
|
||
|
||
# Clean the 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 the persistent storage and retrieval of reports
|
||
|
||
File structure (per-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 report root directory exists"""
|
||
os.makedirs(cls.REPORTS_DIR, exist_ok=True)
|
||
|
||
@classmethod
|
||
def _get_report_folder(cls, report_id: str) -> str:
|
||
"""Get 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 the 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:
|
||
"""Get 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:
|
||
"""Get 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:
|
||
"""Get 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:
|
||
"""Get 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:
|
||
"""Get 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:
|
||
"""Get 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:
|
||
"""Get 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]:
|
||
"""
|
||
Get the console log content
|
||
|
||
This is the console output log (INFO, WARNING, etc.) produced during report
|
||
generation, distinct from the structured log stored in agent_log.jsonl.
|
||
|
||
Args:
|
||
report_id: The report ID
|
||
from_line: The line number to start reading from (for incremental fetch, 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 are available
|
||
}
|
||
"""
|
||
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 the original log line, stripping the 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 the end
|
||
}
|
||
|
||
@classmethod
|
||
def get_console_log_stream(cls, report_id: str) -> List[str]:
|
||
"""
|
||
Get the full console log (fetch all at once)
|
||
|
||
Args:
|
||
report_id: The 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]:
|
||
"""
|
||
Get the Agent log content
|
||
|
||
Args:
|
||
report_id: The report ID
|
||
from_line: The line number to start reading from (for incremental fetch, 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 are available
|
||
}
|
||
"""
|
||
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 the end
|
||
}
|
||
|
||
@classmethod
|
||
def get_agent_log_stream(cls, report_id: str) -> List[Dict[str, Any]]:
|
||
"""
|
||
Get the full Agent log (for fetching all entries at once)
|
||
|
||
Args:
|
||
report_id: The 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 phase 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 per-section output
|
||
|
||
Args:
|
||
report_id: The report ID
|
||
section_index: The section index (1-based)
|
||
section: The section object
|
||
|
||
Returns:
|
||
The saved file path
|
||
"""
|
||
cls._ensure_report_folder(report_id)
|
||
|
||
# Build the section Markdown content - clean up any potentially duplicate headings
|
||
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 the 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 up the section content
|
||
|
||
1. Remove Markdown heading lines at the start of the content that duplicate the section title
|
||
2. Convert all ### and lower-level headings to bold text
|
||
|
||
Args:
|
||
content: The original content
|
||
section_title: The 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 whether this is 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 whether this heading duplicates the section title (skip duplicates within the 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
|
||
# The section title is added by the system, so the content should not contain any headings
|
||
cleaned_lines.append(f"**{title_text}**")
|
||
cleaned_lines.append("") # Add a blank line
|
||
continue
|
||
|
||
# If the previous line was a skipped heading and the current line is empty, skip it as well
|
||
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 lines that follow a 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]]:
|
||
"""Get 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]]:
|
||
"""
|
||
Get the list of generated sections
|
||
|
||
Return 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 the section index from the 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, with heading cleanup
|
||
"""
|
||
folder = cls._get_report_folder(report_id)
|
||
|
||
# Build the 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 across the entire report
|
||
md_content = cls._post_process_report(md_content, outline)
|
||
|
||
# Save the 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 the report content
|
||
|
||
1. Remove duplicate headings
|
||
2. Keep the report main heading (#) and section headings (##); remove other heading levels (###, ####, etc.)
|
||
3. Clean up redundant blank lines and separator lines
|
||
|
||
Args:
|
||
content: The original report content
|
||
outline: The report outline
|
||
|
||
Returns:
|
||
The processed content
|
||
"""
|
||
import re
|
||
|
||
lines = content.split('\n')
|
||
processed_lines = []
|
||
prev_was_heading = False
|
||
|
||
# Collect all section titles from the 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 whether this is 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 whether this is a duplicate heading (same content appearing 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 the duplicate heading and any blank lines that follow
|
||
i += 1
|
||
while i < len(lines) and lines[i].strip() == '':
|
||
i += 1
|
||
continue
|
||
|
||
# Heading level handling:
|
||
# - # (level=1) Keep only the report main heading
|
||
# - ## (level=2) Keep the section headings
|
||
# - ### and below (level>=3) Convert to bold text
|
||
|
||
if level == 1:
|
||
if title == outline.title:
|
||
# Keep the report main heading
|
||
processed_lines.append(line)
|
||
prev_was_heading = True
|
||
elif title in section_titles:
|
||
# Section heading mistakenly used #, correct it 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 the section heading
|
||
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 lower-level 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 a separator line that immediately follows a heading
|
||
i += 1
|
||
continue
|
||
|
||
elif stripped == '' and prev_was_heading:
|
||
# Keep only one blank line after a 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 at most 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 the report metadata and the full report"""
|
||
cls._ensure_report_folder(report.report_id)
|
||
|
||
# Save the metadata 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 the outline
|
||
if report.outline:
|
||
cls.save_outline(report.report_id, report.outline)
|
||
|
||
# Save the 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]:
|
||
"""Get a report"""
|
||
path = cls._get_report_path(report_id)
|
||
|
||
if not os.path.exists(path):
|
||
# Backward compatibility: check for files stored directly in the 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 the 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 to read 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]:
|
||
"""Get a report by 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 compatibility: 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 compatibility: 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 in descending order
|
||
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 entire folder)"""
|
||
import shutil
|
||
|
||
folder_path = cls._get_report_folder(report_id)
|
||
|
||
# New format: delete the 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 compatibility: delete the 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
|