2230 lines
88 KiB
Python
2230 lines
88 KiB
Python
"""
|
|
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. <tool_call>{"name": "tool_name", "parameters": {...}}</tool_call>
|
|
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'<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
|
|
|
|
# Định dạng 2: Dự phòng phòng hờ LLM trả về chuỗi JSON trần không có tag <tool_call>
|
|
# 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 <tool_call> block, do not write Final Answer)\n"
|
|
"- Output final content (starting with 'Final Answer:', do not include <tool_call>)\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('</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
|
|
|
|
# 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'<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]
|
|
}
|
|
|
|
# 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'<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:
|
|
"""
|
|
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
|