""" Dịch vụ Report Agent Sử dụng LangChain + Zep để thực hiện tạo báo cáo mô phỏng theo mô hình ReACT Chức năng: 1. Dựa trên yêu cầu mô phỏng và thông tin đồ thị Zep để tạo ra báo cáo 2. Lên kế hoạch cho cấu trúc mục lục trước, sau đó tạo từng đoạn 3. Mỗi đoạn áp dụng mô hình ReACT để suy nghĩ đa vòng và phản xạ 4. Hỗ trợ hội thoại với người dùng, tự động gọi công cụ tìm kiếm trong hội thoại """ import os import json 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 .zep_tools import ( ZepToolsService, ) from ..prompts.report_agent import ( PLAN_SYSTEM_PROMPT, PLAN_USER_PROMPT_TEMPLATE, SECTION_SYSTEM_PROMPT_TEMPLATE, TOOL_DESC_INSIGHT_FORGE, TOOL_DESC_PANORAMA_SEARCH, TOOL_DESC_QUICK_SEARCH, TOOL_DESC_INTERVIEW_AGENTS, SECTION_USER_PROMPT_TEMPLATE, REACT_OBSERVATION_TEMPLATE, REACT_INSUFFICIENT_TOOLS_MSG, REACT_INSUFFICIENT_TOOLS_MSG_ALT, REACT_TOOL_LIMIT_MSG, REACT_UNUSED_TOOLS_HINT, REACT_FORCE_FINAL_MSG, CHAT_SYSTEM_PROMPT_TEMPLATE, CHAT_OBSERVATION_SUFFIX ) logger = get_logger('mirofish.report_agent') class ReportLogger: """ Trình ghi chi tiết log của Report Agent Tạo file agent_log.jsonl trong thư mục báo cáo, ghi lại chi tiết từng bước. Mỗi dòng là một đối tượng JSON hoàn chỉnh, bao gồm timestamp, loại hành động, nội dung chi tiết... """ def __init__(self, report_id: str): """ Khởi tạo trình ghi log Args: report_id: ID của báo cáo, dùng để quyết định đường dẫn file log """ 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): """Đảm bảo thư mục chứa file log đã tồn tại""" log_dir = os.path.dirname(self.log_file_path) os.makedirs(log_dir, exist_ok=True) def _get_elapsed_time(self) -> float: """Lấy thời gian tiêu tốn từ lúc bắt đầu tới hiện tại (giây)""" 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 ): """ Ghi lại một dòng log Args: action: Loại hành động, ví dụ 'start', 'tool_call', 'llm_response', 'section_complete' v.v.. stage: Giai đoạn hiện tại, ví dụ 'planning', 'generating', 'completed' details: Dictionary chứa nội dung chi tiết section_title: Tiêu đề chương hiện tại (tùy chọn) section_index: Index của chương hiện tại (tùy chọn) """ 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 } # Ghi nối tiếp vào file JSONL 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): """Ghi log báo cáo bắt đầu tạo""" self.log( action="report_start", stage="pending", details={ "simulation_id": simulation_id, "graph_id": graph_id, "simulation_requirement": simulation_requirement, "message": "Report generation task started" } ) def log_planning_start(self): """Ghi log kế hoạch dàn ý bắt đầu""" self.log( action="planning_start", stage="planning", details={"message": "Start planning report outline"} ) def log_planning_context(self, context: Dict[str, Any]): """Ghi log thông tin context lấy được khi lên kế hoạch""" self.log( action="planning_context", stage="planning", details={ "message": "Fetch simulation context info", "context": context } ) def log_planning_complete(self, outline_dict: Dict[str, Any]): """Ghi log kế hoạch dàn ý hoàn thành""" self.log( action="planning_complete", stage="planning", details={ "message": "Outline planning completed", "outline": outline_dict } ) def log_section_start(self, section_title: str, section_index: int): """Ghi log tiến trình bắt đầu tạo chương""" self.log( action="section_start", stage="generating", section_title=section_title, section_index=section_index, details={"message": f"Start generating section: {section_title}"} ) def log_react_thought(self, section_title: str, section_index: int, iteration: int, thought: str): """Ghi log quá trình suy nghĩ ReACT""" self.log( action="react_thought", stage="generating", section_title=section_title, section_index=section_index, details={ "iteration": iteration, "thought": thought, "message": f"ReACT thought iteration {iteration}" } ) def log_tool_call( self, section_title: str, section_index: int, tool_name: str, parameters: Dict[str, Any], iteration: int ): """Ghi log thao tác gọi công cụ""" 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": f"Calling tool: {tool_name}" } ) def log_tool_result( self, section_title: str, section_index: int, tool_name: str, result: str, iteration: int ): """Ghi log kết quả gọi công cụ (Toàn bộ nội dung)""" self.log( action="tool_result", stage="generating", section_title=section_title, section_index=section_index, details={ "iteration": iteration, "tool_name": tool_name, "result": result, # Kết quả đầy đủ, không cắt ngắn "result_length": len(result), "message": f"Tool {tool_name} returned result" } ) def log_llm_response( self, section_title: str, section_index: int, response: str, iteration: int, has_tool_calls: bool, has_final_answer: bool ): """Ghi nhận phản hồi LLM (nội dung đầy đủ, không cắt ngắn)""" self.log( action="llm_response", stage="generating", section_title=section_title, section_index=section_index, details={ "iteration": iteration, "response": response, # Phản hồi đầy đủ, không cắt ngắn "response_length": len(response), "has_tool_calls": has_tool_calls, "has_final_answer": has_final_answer, "message": f"LLM response (Tool call: {has_tool_calls}, Final answer: {has_final_answer})" } ) def log_section_content( self, section_title: str, section_index: int, content: str, tool_calls_count: int ): """Ghi nhận nội dung chương đã tạo (chỉ ghi nhận nội dung, không có nghĩa là toàn bộ chương đã hoàn thành)""" self.log( action="section_content", stage="generating", section_title=section_title, section_index=section_index, details={ "content": content, # Nội dung đầy đủ, không cắt ngắn "content_length": len(content), "tool_calls_count": tool_calls_count, "message": f"Section {section_title} content generation completed" } ) def log_section_full_complete( self, section_title: str, section_index: int, full_content: str ): """ Ghi nhận chương đã hoàn thành Frontend nên lắng nghe nhật ký này để xác định chương đó có thực sự hoàn thành hay không, và lấy nội dung đầy đủ """ 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": f"Section {section_title} generation completed" } ) def log_report_complete(self, total_sections: int, total_time_seconds: float): """Ghi nhận việc tạo báo cáo hoàn tất""" self.log( action="report_complete", stage="completed", details={ "total_sections": total_sections, "total_time_seconds": round(total_time_seconds, 2), "message": "Report generation completed" } ) def log_error(self, error_message: str, stage: str, section_title: str = None): """Ghi nhận lỗi""" self.log( action="error", stage=stage, section_title=section_title, section_index=None, details={ "error": error_message, "message": f"An error occurred: {error_message}" } ) class ReportConsoleLogger: """ Trình ghi Log qua console của Report Agent Ghi nhật ký kiểu console (INFO, WARNING, v.v.) vào tệp console_log.txt trong mục lưu báo cáo. Khác với agent_log.jsonl, những nhật ký này ở định dạng văn bản thuần túy. """ def __init__(self, report_id: str): """ Khởi tạo trình ghi log console Args: report_id: ID báo cáo, dùng để tự xác định đường dẫn file log """ 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): """Đảm bảo thư mục lưu file log tồn tại""" log_dir = os.path.dirname(self.log_file_path) os.makedirs(log_dir, exist_ok=True) def _setup_file_handler(self): """Cấu hình trình xử lý tệp (FileHandler) để ghi nhật ký vào tệp""" import logging # Tạo file handler self._file_handler = logging.FileHandler( self.log_file_path, mode='a', encoding='utf-8' ) self._file_handler.setLevel(logging.INFO) # Cấu trúc log đơn giản tương tự như phiên làm việc console formatter = logging.Formatter( '[%(asctime)s] %(levelname)s: %(message)s', datefmt='%H:%M:%S' ) self._file_handler.setFormatter(formatter) # Thêm file hander vào logger của report_agent loggers_to_attach = [ 'mirofish.report_agent', 'mirofish.zep_tools', ] for logger_name in loggers_to_attach: target_logger = logging.getLogger(logger_name) # Tránh thêm lại handler trùng lặp if self._file_handler not in target_logger.handlers: target_logger.addHandler(self._file_handler) def close(self): """Đóng file handler và gỡ nó khỏi cấu hình 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): """Đảm bảo đóng file handler khi hàm hủy (destructor) gọi""" self.close() class ReportStatus(str, Enum): """Trạng thái báo cáo""" PENDING = "pending" PLANNING = "planning" GENERATING = "generating" COMPLETED = "completed" FAILED = "failed" @dataclass class ReportSection: """Chương báo cáo""" 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: """Chuyển đổi sang định dạng Markdown""" md = f"{'#' * level} {self.title}\n\n" if self.content: md += f"{self.content}\n\n" return md @dataclass class ReportOutline: """Dàn ý báo cáo""" 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: """Chuyển đổi sang định dạng Markdown""" 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: """Báo cáo đầy đủ""" 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 } # ═══════════════════════════════════════════════════════════════ # Class chính: ReportAgent # ═══════════════════════════════════════════════════════════════ class ReportAgent: """ Report Agent - Tác nhân tạo báo cáo mô phỏng Sử dụng chế độ ReACT (Reasoning + Acting): 1. Giai đoạn lập kế hoạch (Planning): Phân tích yêu cầu mô phỏng, hoạch định cấu trúc thư mục báo cáo 2. Giai đoạn tạo văn bản (Generating): Tạo nội dung theo từng cấu trúc, mỗi cấu trúc có thể gọi công cụ nhiều lần để lấy thông tin 3. Giai đoạn xem xét (Reflecting): Kiểm tra tính toàn vẹn và độ chính xác của nội dung """ # Số lần gọi công cụ tối đa (mỗi chương) MAX_TOOL_CALLS_PER_SECTION = 5 # Số vòng xem xét tối đa MAX_REFLECTION_ROUNDS = 3 # Số lần gọi công cụ tối đa trong quá trình trò chuyện MAX_TOOL_CALLS_PER_CHAT = 2 def __init__( self, graph_id: str, simulation_id: str, project_id: str, simulation_requirement: str, llm_client: Optional[LLMClient] = None, zep_tools: Optional[ZepToolsService] = None ): """ Khởi tạo Report Agent Args: graph_id: ID Đồ thị simulation_id: ID mô phỏng simulation_requirement: Mô tả yêu cầu mô phỏng llm_client: Client của LLM (không bắt buộc) zep_tools: Dịch vụ công cụ Zep (không bắt buộc) """ self.graph_id = graph_id self.simulation_id = simulation_id self.project_id = project_id self.simulation_requirement = simulation_requirement self.llm = llm_client or LLMClient( component="report_agent", metadata={ "simulation_id": simulation_id, "project_id": project_id, "phase": "report_generation", }, ) self.zep_tools = zep_tools or ZepToolsService( llm_client=LLMClient( component="zep_tools", metadata={ "simulation_id": simulation_id, "project_id": project_id, "phase": "zep_tools", }, ) ) # Định nghĩa các công cụ self.tools = self._define_tools() # Trình ghi log báo cáo (được khởi tạo trong generate_report) self.report_logger: Optional[ReportLogger] = None # Trình ghi log console (được khởi tạo trong generate_report) self.console_logger: Optional[ReportConsoleLogger] = None logger.info( f"ReportAgent initialized: graph_id={graph_id}, simulation_id={simulation_id}, project_id={project_id}" ) def _define_tools(self) -> Dict[str, Dict[str, Any]]: """Định nghĩa các công cụ khả dụng""" return { "insight_forge": { "name": "insight_forge", "description": TOOL_DESC_INSIGHT_FORGE, "parameters": { "query": "The question or topic you want to deeply analyze", "report_context": "The 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 returned results (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 opinions on dorm formaldehyde issue')", "max_agents": "Maximum number of agents to interview (optional, default 5, maximum 10)" } } } def _execute_tool(self, tool_name: str, parameters: Dict[str, Any], report_context: str = "") -> str: """ Thực thi lệnh gọi công cụ Args: tool_name: Tên công cụ parameters: Các tham số cho công cụ report_context: Ngữ cảnh của đoạn báo cáo (dành cho InsightForge) Returns: Kết quả của công cụ trả về (định dạng text) """ logger.info(f"Executing tool: {tool_name}, Parameters: {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": # Tìm kiếm diện rộng - Lấy toàn cảnh kết quả 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": # Tìm kiếm đơn giản - Lấy dữ liệu nhanh 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": # Phỏng vấn chuyên sâu - Gọi API phỏng vấn OASIS thật để lấy ý kiến của các Agent đang chạy (đa nền tảng) 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() # ========== Công cụ cũ giữ lại để tương thích (chuyển hướng sang công cụ mới) ========== elif tool_name == "search_graph": # Lái qua quick_search logger.info("search_graph redirected to quick_search") 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": # Lái qua insight_forge do nó xịn hơn logger.info("get_simulation_context redirected to insight_forge") 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: insight_forge, panorama_search, quick_search" except Exception as e: logger.error(f"Tool execution failed: {tool_name}, Error: {str(e)}") return f"Tool execution failed: {str(e)}" # Tập hợp các tool khả dụng, để kiểm tra tính hợp lệ khi quét JSON VALID_TOOL_NAMES = {"insight_forge", "panorama_search", "quick_search", "interview_agents"} def _parse_tool_calls(self, response: str) -> List[Dict[str, Any]]: """ Phân tích kết quả trả về từ LLM để lấy thông tin gọi công cụ Định dạng hỗ trợ (theo mức độ ưu tiên): 1. {"name": "tool_name", "parameters": {...}} 2. JSON trần (Toàn bộ chuỗi response hoặc từng dòng là một JSON trực tiếp cho công cụ) """ tool_calls = [] # Định dạng 1: Phong cách XML (Định dạng tiêu chuẩn) xml_pattern = r'\s*(\{.*?\})\s*' 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 # Định dạng 2: Dự phòng phòng hờ LLM trả về chuỗi JSON trần không có tag # Chỉ chạy logic này khi không thấy định dạng 1, tránh dính JSON vô ý trong văn bản chính 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 # Reply có thể chứa cả nội dung suy nghĩ (Thought) + JSON trần, thử móc ra object JSON cuối cùng 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: """Kiểm tra JSON giải mã được có phải là lời gọi công cụ hợp lệ hay không""" # Hỗ trợ cả 2 định dạng {"name": ..., "parameters": ...} và {"tool": ..., "params": ...} tool_name = data.get("name") or data.get("tool") if tool_name and tool_name in self.VALID_TOOL_NAMES: # Đồng nhất key về chuẩn 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: """Tạo đoạn văn mô tả công cụ""" 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: """ Hoạch định dàn ý báo cáo Sử dụng LLM để phân tích yêu cầu mô phỏng, hoạch định cấu trúc thư mục của báo cáo Args: progress_callback: Hàm callback tiến trình Returns: ReportOutline: Dàn ý báo cáo """ logger.info("Start planning report outline...") if progress_callback: progress_callback("planning", 0, "Analyzing simulation requirements...") # Đầu tiên cần lấy ngữ cảnh mô phỏng context = self.zep_tools.get_simulation_context( graph_id=self.graph_id, simulation_requirement=self.simulation_requirement ) if progress_callback: progress_callback("planning", 30, "Generating report outline...") system_prompt = PLAN_SYSTEM_PROMPT 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, "Parsing outline structure...") # Phân tích cú pháp lấy dàn ý 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, "Outline planning completed") logger.info(f"Outline planning completed: {len(sections)} chapters") return outline except Exception as e: logger.error(f"Outline planning failed: {str(e)}") # Trả về dàn ý mặc định (3 chương, đóng vai trò bản dự phòng) return ReportOutline( title="Future Prediction Report", summary="Future trends and risk analysis based on simulation prediction", sections=[ ReportSection(title="Prediction Scenarios and Core Findings"), ReportSection(title="Population 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: """ Sử dụng chế độ ReACT để tạo nội dung cho từng chương Vòng lặp ReACT: 1. Thought (Suy nghĩ) - Phân tích xem cần thông tin gì 2. Action (Hành động) - Gọi công cụ để lấy thông tin 3. Observation (Quan sát) - Phân tích kết quả công cụ trả về 4. Lặp lại quá trình tới khi đủ thông tin hoặc chạy hết số lượt cho phép 5. Final Answer (Câu trả lời cuối cùng) - Sinh nội dung chương Args: section: Chương cần sinh outline: Dàn ý đầy đủ previous_sections: Nội dung các chương trước (dành cho việc duy trì tính gắn kết logic) progress_callback: Callback theo dõi tiến trình section_index: Chỉ mục của chương hiện tại (dùng để log) Returns: Nội dung chương (Định dạng Markdown) """ logger.info(f"ReACT generating chapter: {section.title}") # Ghi nhận log khởi tạo chương 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(), ) # Xây dựng prompt cho user - mỗi chương trước đó sẽ truyền vào tối đa 4000 ký tự if previous_sections: previous_parts = [] for sec in previous_sections: # Mỗi chương tối đa 4000 ký tự 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} ] # Vòng lặp ReACT tool_calls_count = 0 max_iterations = 5 # Số vòng lặp tối đa min_tool_calls = 3 # Số lần gọi công cụ tối thiểu conflict_retries = 0 # Số lần gọi công cụ và trả về Final Answer bị xung đột liên tiếp used_tools = set() # Lưu lại tên các công cụ đã được gọi all_tools = {"insight_forge", "panorama_search", "quick_search", "interview_agents"} # Ngữ cảnh báo cáo, dùng để tự sinh câu hỏi thứ cấp trong InsightForge report_context = f"Chapter 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), f"Deep retrieval and writing in progress ({tool_calls_count}/{self.MAX_TOOL_CALLS_PER_SECTION})" ) # Gọi LLM response = self.llm.chat( messages=messages, temperature=0.5, # max_tokens=4096 ) # Kiểm tra xem phản hồi có rỗng/chưa có (None) không (do API lỗi hoặc content null) if response is None: logger.warning(f"Chapter {section.title} iteration {iteration + 1}: LLM returned None") # Nếu còn lượt thử, thêm message tiếp if iteration < max_iterations - 1: messages.append({"role": "assistant", "content": "(Empty response)"}) messages.append({"role": "user", "content": "Please continue generating content."}) continue # Còn nếu nó về None lần cuối thì nhảy thoát vòng lặp kết thúc break logger.debug(f"LLM response: {response[:200]}...") # Parse dữ liệu một lần để tiết kiệm tool_calls = self._parse_tool_calls(response) has_tool_calls = bool(tool_calls) has_final_answer = "Final Answer:" in response # ── Giải quyết xung đột: LLM cùng lúc nhả cả tool gọi lẫn output Final Answer ── if has_tool_calls and has_final_answer: conflict_retries += 1 logger.warning( f"Chapter {section.title} iteration {iteration+1}: " f"LLM output tool call and Final Answer simultaneously (Conflict #{conflict_retries})" ) if conflict_retries <= 2: # Trong 2 lần đầu: Vứt phản hồi này, bắt LLM tạo lại messages.append({"role": "assistant", "content": response}) messages.append({ "role": "user", "content": ( "[Format Error] You included both tool call and Final Answer in a single reply, which is not allowed.\n" "Each reply can only do one of two things:\n" "- Call a tool (output a block, do not write Final Answer)\n" "- Output final content (starting with 'Final Answer:', do not include )\n" "Please reply again, doing only one of these things." ), }) continue else: # Lần 3: Hạ cấp xử lý, ngắt cứng đến lệnh gọi công cụ đầu tiên và tiếp tục bám theo logger.warning( f"Chapter {section.title}: {conflict_retries} consecutive conflicts, " "degrading to truncated execution of the first tool call" ) first_tool_end = response.find('') if first_tool_end != -1: response = response[:first_tool_end + len('')] tool_calls = self._parse_tool_calls(response) has_tool_calls = bool(tool_calls) has_final_answer = False conflict_retries = 0 # Ghi lại log phản hồi của LLM 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 ) # ── Trường hợp 1: LLM đã xuất ra Final Answer ── if has_final_answer: # Nếu số lần gọi công cụ chưa đủ, từ chối và yêu cầu tiếp tục gọi if tool_calls_count < min_tool_calls: messages.append({"role": "assistant", "content": response}) unused_tools = all_tools - used_tools unused_hint = f"(These tools are unused, suggest using 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 # Kết thúc bình thường final_answer = response.split("Final Answer:")[-1].strip() logger.info(f"Chapter {section.title} generation completed (Tool calls: {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 # ── Trường hợp 2: LLM cố gắng gọi công cụ ── if has_tool_calls: # Đã hết hạn mức gọi công cụ → Thông báo rõ ràng, yêu cầu xuất 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 # Chỉ thực thi lệnh gọi đầu tiên call = tool_calls[0] if len(tool_calls) > 1: logger.info(f"LLM attempted to call {len(tool_calls)} tools, only executing the first one: {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']) # Tạo gợi ý cho các công cụ chưa dùng 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 # ── Trường hợp 3: Không có gọi công cụ, cũng không có Final Answer ── messages.append({"role": "assistant", "content": response}) if tool_calls_count < min_tool_calls: # Gọi công cụ chưa đủ, gợi ý các công cụ khác unused_tools = all_tools - used_tools unused_hint = f"(These tools are unused, suggest using 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 # Đã đủ số lần gọi công cụ, LLM sinh nội dung nhưng quên mất tiền tố "Final Answer:" # Cứ lấy thẳng nội dung này làm kết quả luôn cho khỏi vòng vo logger.info(f"Chapter {section.title} 'Final Answer:' prefix not detected, directly adopting LLM output as final content (Tool calls: {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 # Hết số vòng lặp tối đa, bắt buộc bắt đầu tự sản xuất logger.warning(f"Chapter {section.title} reached max iterations, forcing generation") messages.append({"role": "user", "content": REACT_FORCE_FINAL_MSG}) response = self.llm.chat( messages=messages, temperature=0.5, # max_tokens=4096 ) # Kiểm tra nếu ép buộc kết thúc mà LLM vẫn nhả None if response is None: logger.error(f"Chapter {section.title} LLM returned None during forced completion, using default error prompt") final_answer = f"(This chapter generation failed: LLM returned empty response, please try again later)" elif "Final Answer:" in response: final_answer = response.split("Final Answer:")[-1].strip() else: final_answer = response # Ghi log quá trình tạo nội dung chương hoàn tất 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: """ Tạo báo cáo hoàn chỉnh (Xuất theo thời gian thực từng chương) Mỗi chương sau khi hoàn thành sẽ được lưu ngay vào mục, không cần đợi cả báo cáo xong. Cấu trúc thư mục: reports/{report_id}/ meta.json - Thông tin meta outline.json - Dàn ý progress.json - Tiến độ section_01.md - Chương 1 section_02.md - Chương 2 ... full_report.md - Báo cáo tổng Args: progress_callback: Callback tiến độ (stage, progress, message) report_id: ID báo cáo (Có thể rỗng để tự phát sinh) Returns: Report: Báo cáo đầy đủ """ import uuid # Tạo report_id mới tự động nếu chưa có truyền vào if not report_id: report_id = f"report_{uuid.uuid4().hex[:12]}" start_time = datetime.now() # Đồng bộ metadata chi phí LLM theo report hiện tại # Mục tiêu: mọi call trong giai đoạn generate report đều mang report_id. self.llm.default_metadata["report_id"] = report_id self.llm.default_metadata["project_id"] = self.project_id self.llm.default_metadata["simulation_id"] = self.simulation_id self.llm.default_metadata["phase"] = "report_generation" zep_llm = self.zep_tools.llm zep_llm.default_metadata["report_id"] = report_id zep_llm.default_metadata["project_id"] = self.project_id zep_llm.default_metadata["simulation_id"] = self.simulation_id zep_llm.default_metadata["phase"] = "zep_tools" 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() ) # Cập nhật mảng những tiêu đề chương xong (Để tính tiến độ) completed_section_titles = [] try: # Khởi tạo: Tạo thư mục lưu và ghi nhận trạng thái ReportManager._ensure_report_folder(report_id) # Khởi tạo bộ ghi log (Log có cấu trúc ghi ra file 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 ) # Khởi tạo logger để lưu thêm console (console_log.txt) self.console_logger = ReportConsoleLogger(report_id) ReportManager.update_progress( report_id, "pending", 0, "Initializing report...", completed_sections=[] ) ReportManager.save_report(report) # Bước 1: Lên dàn ý report.status = ReportStatus.PLANNING ReportManager.update_progress( report_id, "planning", 5, "Start planning report outline...", completed_sections=[] ) # Ghi log bắt đầu giai đoạn lên dàn ý self.report_logger.log_planning_start() if progress_callback: progress_callback("planning", 0, "Start planning report outline...") outline = self.plan_outline( progress_callback=lambda stage, prog, msg: progress_callback(stage, prog // 5, msg) if progress_callback else None ) report.outline = outline # Ghi log hoàn thành dàn ý self.report_logger.log_planning_complete(outline.to_dict()) # Lưu file dàn ý ReportManager.save_outline(report_id, outline) ReportManager.update_progress( report_id, "planning", 15, f"Outline planning completed, total {len(outline.sections)} chapters", completed_sections=[] ) ReportManager.save_report(report) logger.info(f"Outline saved to file: {report_id}/outline.json") # Giai đoạn 2: Tạo từng chương (lưu theo từng chương) report.status = ReportStatus.GENERATING total_sections = len(outline.sections) generated_sections = [] # Lưu nội dung lại cho ngữ cảnh những lần sau for i, section in enumerate(outline.sections): section_num = i + 1 base_progress = 20 + int((i / total_sections) * 70) # Cập nhật tiến độ ReportManager.update_progress( report_id, "generating", base_progress, f"Generating chapter: {section.title} ({section_num}/{total_sections})", current_section=section.title, completed_sections=completed_section_titles ) if progress_callback: progress_callback( "generating", base_progress, f"Generating chapter: {section.title} ({section_num}/{total_sections})" ) # Tạo nội dung chương 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}") # Lưu chương ReportManager.save_section(report_id, section_num, section) completed_section_titles.append(section.title) # Ghi lại kết quả khi chương ra lò 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(f"Chapter saved: {report_id}/section_{section_num:02d}.md") # Cập nhật thanh tiến độ ReportManager.update_progress( report_id, "generating", base_progress + int(70 / total_sections), f"Chapter {section.title} completed", current_section=None, completed_sections=completed_section_titles ) # Giai đoạn 3: Ghép lại toàn bộ báo cáo if progress_callback: progress_callback("generating", 95, "Assembling full report...") ReportManager.update_progress( report_id, "generating", 95, "Assembling full report...", completed_sections=completed_section_titles ) # Dùng ReportManager để ráp báo cáo report.markdown_content = ReportManager.assemble_full_report(report_id, outline) report.status = ReportStatus.COMPLETED report.completed_at = datetime.now().isoformat() # Tính toán thời gian total_time_seconds = (datetime.now() - start_time).total_seconds() # Ghi nhận log khi tạo thành công if self.report_logger: self.report_logger.log_report_complete( total_sections=total_sections, total_time_seconds=total_time_seconds ) # Lưu kết quả cuối cùng ReportManager.save_report(report) ReportManager.update_progress( report_id, "completed", 100, "Report generation completed", completed_sections=completed_section_titles ) if progress_callback: progress_callback("completed", 100, "Report generation completed") logger.info(f"Report generation completed: {report_id}") # Tắt ghi log console if self.console_logger: self.console_logger.close() self.console_logger = None return report except Exception as e: logger.error(f"Report generation failed: {str(e)}") report.status = ReportStatus.FAILED report.error = str(e) # Ghi lại log lỗi if self.report_logger: self.report_logger.log_error(str(e), "failed") # Lưu lại trạng thái failed try: ReportManager.save_report(report) ReportManager.update_progress( report_id, "failed", -1, f"Report generation failed: {str(e)}", completed_sections=completed_section_titles ) except Exception: pass # Lờ đi nếu bị lỗi trong khi lưu # Tắt console log 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]: """ Trò chuyện cùng Report Agent Trong quá trình chat, Agent có khả năng tự gọi công cụ tìm kiếm để trả lời Args: message: Tin nhắn mới của người dùng chat_history: Lịch sử đàm thoại Returns: { "response": "Nội dung trả lời của Agent", "tool_calls": [Danh sách các công cụ đã sử dụng], "sources": [Nguồn thông tin] } """ logger.info(f"Report Agent chat: {message[:50]}...") chat_history = chat_history or [] # Nhúng nội dung báo cáo cũ vào report_content = "" try: report = ReportManager.get_report_by_simulation(self.simulation_id) if report and report.markdown_content: # Cắt bớt độ dài vì tránh nghẽn bộ nhớ 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(f"Failed to get report content: {e}") system_prompt = CHAT_SYSTEM_PROMPT_TEMPLATE.format( simulation_requirement=self.simulation_requirement, report_content=report_content if report_content else "(No report available)", tools_description=self._get_tools_description(), ) # Xây dựng message messages = [{"role": "system", "content": system_prompt}] # Nạp lịch sử cuộc trò chuyện for h in chat_history[-10:]: # Giới hạn số lượng tin nhắn quá khứ messages.append(h) # Nạp tin nhắn mới nhất messages.append({ "role": "user", "content": message }) # Vòng lặp ReACT (phiên bản thu gọn) tool_calls_made = [] max_iterations = 2 # Giảm số lần lặp for iteration in range(max_iterations): response = self.llm.chat( messages=messages, temperature=0.5 ) # Phân tích lệnh gọi công cụ tool_calls = self._parse_tool_calls(response) if not tool_calls: # Không thấy gọi công cụ, trả luôn kết quả clean_response = re.sub(r'.*?', '', 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] } # Khởi chạy công cụ (giới hạn số lượng) tool_results = [] for call in tool_calls[:1]: # Cả vòng chỉ cho chạy công cụ tối đa 1 lần 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] # Giới hạn kích thước kết quả để tránh nghẽn }) tool_calls_made.append(call) # Đắp kết quả vào 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 }) # Đã đạt giới hạn lặp, sinh câu trả lời chốt chặn final_response = self.llm.chat( messages=messages, temperature=0.5 ) # Dọn dẹp phản hồi clean_response = re.sub(r'.*?', '', 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: """ Trình Quản lý Báo cáo Phụ trách việc lưu trữ lâu dài và trích xuất báo cáo Cấu trúc thư mục (Lưu báo cáo phân thành từng chương): reports/ {report_id}/ meta.json - File metadata và trạng thái outline.json - Dàn ý progress.json - Tiến trình phát sinh báo cáo section_01.md - Chương 1 section_02.md - Chương 2 ... full_report.md - Báo cáo hoàn chỉnh """ # Phân vùng lưu trữ gốc REPORTS_DIR = os.path.join(Config.UPLOAD_FOLDER, 'reports') @classmethod def _ensure_reports_dir(cls): """Đảm bảo thư mục lưu trữ gốc luôn tồn tại""" os.makedirs(cls.REPORTS_DIR, exist_ok=True) @classmethod def _get_report_folder(cls, report_id: str) -> str: """Lấy đường dẫn thư mục báo cáo""" return os.path.join(cls.REPORTS_DIR, report_id) @classmethod def _ensure_report_folder(cls, report_id: str) -> str: """Đảm bảo thư mục báo cáo tồn tại (và trả lại đường dẫn)""" 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: """Lấy file chi tiết cơ bản của báo cáo""" return os.path.join(cls._get_report_folder(report_id), "meta.json") @classmethod def _get_report_markdown_path(cls, report_id: str) -> str: """Lấy file báo cáo dạng Markdown hợp nhất""" return os.path.join(cls._get_report_folder(report_id), "full_report.md") @classmethod def _get_outline_path(cls, report_id: str) -> str: """Lấy file mang nội dung dàn ý""" return os.path.join(cls._get_report_folder(report_id), "outline.json") @classmethod def _get_progress_path(cls, report_id: str) -> str: """Lấy file tiến độ tạo báo cáo""" 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: """Lấy file cho một chương Markdown chỉ định""" 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: """Đường dẫn của Nhật ký Agent""" return os.path.join(cls._get_report_folder(report_id), "agent_log.jsonl") @classmethod def _get_console_log_path(cls, report_id: str) -> str: """Đường dẫn của file console log""" 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]: """ Lấy nội dung ghi chép file console_log Đây là text được in ra khi tạo báo cáo (INFO, WARNING, v.v.), Khác với báo cáo có cấu trúc JSON của agent_log.jsonl Args: report_id: ID báo cáo from_line: Bắt đầu lấy từ dòng nào (để phục vụ chế độ tải tuần tự cho front-end, truyền 0 để lấy từ đầu) Returns: { "logs": [Danh sách text log], "total_lines": Tổng dòng hiện có, "from_line": Số thứ tự dòng bắt đầu, "has_more": Cờ xét còn trang sau không } """ 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: # Giữ nguyên bản text, gạt bỏ breakline cuối chuỗi logs.append(line.rstrip('\n\r')) return { "logs": logs, "total_lines": total_lines, "from_line": from_line, "has_more": False # Vì đã đọc kịch sàn file } @classmethod def get_console_log_stream(cls, report_id: str) -> List[str]: """ Lấy toàn bộ console log (lấy một lần duy nhất) Args: report_id: ID báo cáo Returns: Danh sách dòng log """ 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]: """ Lấy nội dung Agent log Args: report_id: ID báo cáo from_line: Bắt đầu lấy từ dòng nào (để phục vụ chế độ tải tuần tự cho front-end, truyền 0 để lấy từ đầu) Returns: { "logs": [Danh sách đối tượng log], "total_lines": Tổng dòng hiện có, "from_line": Số thứ tự dòng bắt đầu, "has_more": Cờ xét còn trang sau không } """ 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: # Bỏ qua những dòng parse trượt continue return { "logs": logs, "total_lines": total_lines, "from_line": from_line, "has_more": False # Đã tới kịch trần file } @classmethod def get_agent_log_stream(cls, report_id: str) -> List[Dict[str, Any]]: """ Lấy toàn bộ nội dung agent_log (để lấy một cú tóm gọn) Args: report_id: ID báo cáo Returns: Danh sách đối tượng log """ result = cls.get_agent_log(report_id, from_line=0) return result["logs"] @classmethod def save_outline(cls, report_id: str, outline: ReportOutline) -> None: """ Lưu báo cáo dàn ý Gọi ngay lập tức khi hoàn thành việc lên kế hoạch (planning) """ 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(f"Outline saved: {report_id}") @classmethod def save_section( cls, report_id: str, section_index: int, section: ReportSection ) -> str: """ Lưu từng chương đơn lẻ Gọi ngay sau khi tạo xong chương báo cáo, thực hiện việc in ấn từng phần Args: report_id: ID báo cáo section_index: Chỉ mục chương (Cơ sở 1) section: Đối tượng chương Returns: Đường dẫn thư mục được ghi """ cls._ensure_report_folder(report_id) # Build Markdown content cho chương - xóa những tiêu đề trùng (nếu có) 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" # Ghi đè tệp tin 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(f"Chapter saved: {report_id}/{file_suffix}") return file_path @classmethod def _clean_section_content(cls, content: str, section_title: str) -> str: """ Làm sạch nội dung chương 1. Xóa các dòng tiêu đề Markdown ở đầu trùng lặp với tiêu đề chương 2. Biến đổi những tiêu đề cấp ### trở xuống thành in đậm Args: content: Nội dung thô section_title: Tiêu đề chương Returns: Nội dung sau khi làm sạch """ 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() # Kiểm tra dòng này có phải heading sinh từ MD không 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() # Bỏ qua nếu tiêu đề ở tầm 5 dòng đầu lại trùng khớp với nhan đề chính if i < 5: if title_text == section_title or title_text.replace(' ', '') == section_title.replace(' ', ''): skip_next_empty = True continue # Đổi toàn bộ các tag MD heading (#, ##, ###, v.v...) sang in đậm Text # Do heading đã được config sinh từ tool gốc, không nên có MD style tại đây cleaned_lines.append(f"**{title_text}**") cleaned_lines.append("") # Gắn thêm dòng trống continue # Nếu dòng kề là dòng trắng thuộc chuỗi heading trùng lặp, cần lược bỏ luôn if skip_next_empty and stripped == '': skip_next_empty = False continue skip_next_empty = False cleaned_lines.append(line) # Bỏ đi mấy block dòng trắng vô danh ở mở đầu while cleaned_lines and cleaned_lines[0].strip() == '': cleaned_lines.pop(0) # Xóa các gạch đường phân cách ở đầu while cleaned_lines and cleaned_lines[0].strip() in ['---', '***', '___']: cleaned_lines.pop(0) # Remove đồng thời new lines gắn theo gạch ngang 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: """ Cập nhật tiến độ tạo báo cáo Frontend có thể lấy tiến độ real-time theo progress.json """ 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]]: """Lấy tiến độ tạo báo cáo""" 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]]: """ Lấy danh sách các chương đã tạo xong Trả về toàn bộ thông tin tệp từng phần """ 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() # Phân tích index của chương từ tên tệp 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: """ Lắp ráp toàn bộ báo cáo Từ các chương đã lưu, tổng chắp báo cáo lại và dọn dẹp nhan đề """ folder = cls._get_report_folder(report_id) # Build phần Header báo cáo md_content = f"# {outline.title}\n\n" md_content += f"> {outline.summary}\n\n" md_content += f"---\n\n" # Đọc files từ mọi chương theo thứ tự sections = cls.get_generated_sections(report_id) for section_info in sections: md_content += section_info["content"] # Hậu kiểm: dọn dẹp cấu trúc nhan đề trong toàn bộ file Markdown md_content = cls._post_process_report(md_content, outline) # Ghi lại tệp rốt cục 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(f"Full report assembled: {report_id}") return md_content @classmethod def _post_process_report(cls, content: str, outline: ReportOutline) -> str: """ Hậu kiểm nội dung báo cáo 1. Xóa nhan đề trùng lặp 2. Dữ nguyên nhan đề chính (#) và nhan đề chương (##), xóa/giảm các tag nhan đề phụ (###, #### v.v) 3. Dọn dẹp khoảng trống và đường ranh giới thừa thãi Args: content: Nội dung thô của báo cáo outline: Dàn ý Returns: Nội dung sau khi xử lý """ import re lines = content.split('\n') processed_lines = [] prev_was_heading = False # Gom góp tất thảy nhan đề từ 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() # Kiểm tra coi đây có là nhan đề hay không heading_match = re.match(r'^(#{1,6})\s+(.+)$', stripped) if heading_match: level = len(heading_match.group(1)) title = heading_match.group(2).strip() # Kiểm tra nhan đề này đang quẩn quanh lặp lại chuỗi cũ chăng (ngưỡng 5 dòng) 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: # Rũ đi dòng nhan đề trùng lặp và các dòng newline ngay sau i += 1 while i < len(lines) and lines[i].strip() == '': i += 1 continue # Xử lý quy mô cấp bậc tiêu đề: # - # (level=1) Chỉ dành cho nhan đề lõi của toàn văn kiện # - ## (level=2) Bám dính tiêu đề chương mục # - ### và thấp hơn (level>=3) Băm thành chữ in đậm if level == 1: if title == outline.title: # Giữ nhan đề gốc processed_lines.append(line) prev_was_heading = True elif title in section_titles: # Do nhan đề chương đánh bừa ra thẻ #, buộc ép về lại ## processed_lines.append(f"## {title}") prev_was_heading = True else: # Các tiêu đề cấp 1 tạp nham cho in đậm processed_lines.append(f"**{title}**") processed_lines.append("") prev_was_heading = False elif level == 2: if title in section_titles or title == outline.title: # Bảo lưu nhan đề chương processed_lines.append(line) prev_was_heading = True else: # Nếu chả phải chương thì in đậm nốt thẻ cấp 2 processed_lines.append(f"**{title}**") processed_lines.append("") prev_was_heading = False else: # Thẻ cấp 3 trở đi cho in đậm processed_lines.append(f"**{title}**") processed_lines.append("") prev_was_heading = False i += 1 continue elif stripped == '---' and prev_was_heading: # Tránh lôi đường chia gạch dưới title i += 1 continue elif stripped == '' and prev_was_heading: # Ngậm 1 dòng trắng kề sau nhan đề 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 # Vuốt láng khoảng trắng đa tầng (chừa 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: """Lưu lại metadata và toàn văn báo cáo""" cls._ensure_report_folder(report.report_id) # Ghi meta file dạng 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) # Ghi mục lục tổng quan if report.outline: cls.save_outline(report.report_id, report.outline) # Lưu file MD trọn gói 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(f"Report saved: {report.report_id}") @classmethod def get_report(cls, report_id: str) -> Optional[Report]: """Lấy về toàn văn mục báo cáo""" path = cls._get_report_path(report_id) if not os.path.exists(path): # Tương thích ngược: thử xem file .json trần trụi ở ngay thư mục gốc 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) # Cấu trúc lại model 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 ) # Nếu chưa bếch được đoạn markdown thì tìm từ file .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]: """Tìm báo cáo thông qua ID phiến giả lập""" cls._ensure_reports_dir() for item in os.listdir(cls.REPORTS_DIR): item_path = os.path.join(cls.REPORTS_DIR, item) # Định dạng mới coi nó là directory if os.path.isdir(item_path): report = cls.get_report(item) if report and report.simulation_id == simulation_id: return report # Tương thích format cũ: .json 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]: """Liệt kê rổ báo cáo""" cls._ensure_reports_dir() reports = [] for item in os.listdir(cls.REPORTS_DIR): item_path = os.path.join(cls.REPORTS_DIR, item) # Standard mới 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) # Standard quá độ .json 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 descending bởi thời gian tạo reports.sort(key=lambda r: r.created_at, reverse=True) return reports[:limit] @classmethod def delete_report(cls, report_id: str) -> bool: """Xóa rễ cụm folder tương ứng với report""" import shutil folder_path = cls._get_report_folder(report_id) # Định dạng mới quăng folder là mượt if os.path.exists(folder_path) and os.path.isdir(folder_path): shutil.rmtree(folder_path) logger.info(f"Report folder removed: {report_id}") return True # Mode tương thích: xóa 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