feat: report quality overhaul — concrete data, agent quotes, infographic
- Add SimulationAnalyticsService: direct actions.jsonl access for stats, top posts, agent quotes (positive/negative), sentiment breakdown - Add simulation_analytics tool to ReportAgent ReACT loop - Rewrite prompts: demand specific numbers, prohibit vague language, require verbatim agent quotes (min 5 per section) - Refactor report_agent.py: extract prompts → report_prompts.py, data classes → report_data.py (both under 800 lines) - Add ReportInfographic.vue: metrics cards, action distribution bars, sentiment breakdown, top agents, timeline sparkline - Add infographic API endpoint: GET /api/report/<id>/infographic - Pre-compute infographic data during report generation - Increase max_tokens from 4096 to 8192 for detailed sections
This commit is contained in:
parent
9efdced7f2
commit
cd51ebe282
|
|
@ -10,7 +10,9 @@ from flask import request, jsonify, send_file
|
|||
|
||||
from . import report_bp
|
||||
from ..config import Config
|
||||
from ..services.report_agent import ReportAgent, ReportManager, ReportStatus
|
||||
from ..services.report_agent import ReportAgent
|
||||
from ..services.report_data import ReportManager, ReportStatus
|
||||
from ..services.simulation_analytics import SimulationAnalyticsService
|
||||
from ..services.simulation_manager import SimulationManager
|
||||
from ..models.project import ProjectManager
|
||||
from ..models.task import TaskManager, TaskStatus
|
||||
|
|
@ -1022,3 +1024,40 @@ def get_graph_statistics_tool():
|
|||
"error": str(e),
|
||||
"traceback": traceback.format_exc()
|
||||
}), 500
|
||||
|
||||
|
||||
# ============== 信息图数据接口 ==============
|
||||
|
||||
@report_bp.route('/<report_id>/infographic', methods=['GET'])
|
||||
def get_report_infographic(report_id: str):
|
||||
"""
|
||||
获取报告的信息图仪表板数据
|
||||
|
||||
返回模拟行为统计数据,用于前端渲染信息图。
|
||||
优先从报告文件夹读取缓存数据,如无则实时计算。
|
||||
"""
|
||||
try:
|
||||
cached = ReportManager.get_infographic(report_id)
|
||||
if cached:
|
||||
return jsonify({"success": True, "data": cached})
|
||||
|
||||
report = ReportManager.get_report(report_id)
|
||||
if not report:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Report not found",
|
||||
}), 404
|
||||
|
||||
analytics = SimulationAnalyticsService()
|
||||
data = analytics.get_infographic_data(report.simulation_id)
|
||||
|
||||
try:
|
||||
ReportManager.save_infographic(report_id, data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({"success": True, "data": data})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取信息图数据失败: {str(e)}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,481 @@
|
|||
"""
|
||||
Report 数据类、日志记录器和报告管理器
|
||||
|
||||
从 report_agent.py 中提取,包含:
|
||||
- ReportStatus, ReportSection, ReportOutline, Report 数据类
|
||||
- ReportLogger (结构化 agent_log.jsonl)
|
||||
- ReportConsoleLogger (控制台 console_log.txt)
|
||||
- ReportManager (文件持久化)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from ..config import Config
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.locale import t
|
||||
|
||||
logger = get_logger('foresight.report_data')
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 数据类
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class ReportStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
PLANNING = "planning"
|
||||
GENERATING = "generating"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportSection:
|
||||
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:
|
||||
md = f"{'#' * level} {self.title}\n\n"
|
||||
if self.content:
|
||||
md += f"{self.content}\n\n"
|
||||
return md
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportOutline:
|
||||
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:
|
||||
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:
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# ReportLogger — 结构化 agent_log.jsonl
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class ReportLogger:
|
||||
def __init__(self, report_id: str):
|
||||
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):
|
||||
os.makedirs(os.path.dirname(self.log_file_path), exist_ok=True)
|
||||
|
||||
def _elapsed(self) -> float:
|
||||
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):
|
||||
entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"elapsed_seconds": round(self._elapsed(), 2),
|
||||
"report_id": self.report_id,
|
||||
"action": action,
|
||||
"stage": stage,
|
||||
"section_title": section_title,
|
||||
"section_index": section_index,
|
||||
"details": details,
|
||||
}
|
||||
with open(self.log_file_path, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
||||
|
||||
def log_start(self, simulation_id: str, graph_id: str, simulation_requirement: str):
|
||||
self.log("report_start", "pending", {
|
||||
"simulation_id": simulation_id,
|
||||
"graph_id": graph_id,
|
||||
"simulation_requirement": simulation_requirement,
|
||||
"message": t('report.taskStarted'),
|
||||
})
|
||||
|
||||
def log_planning_start(self):
|
||||
self.log("planning_start", "planning", {"message": t('report.planningStart')})
|
||||
|
||||
def log_planning_context(self, context: Dict[str, Any]):
|
||||
self.log("planning_context", "planning", {
|
||||
"message": t('report.fetchSimContext'),
|
||||
"context": context,
|
||||
})
|
||||
|
||||
def log_planning_complete(self, outline_dict: Dict[str, Any]):
|
||||
self.log("planning_complete", "planning", {
|
||||
"message": t('report.planningComplete'),
|
||||
"outline": outline_dict,
|
||||
})
|
||||
|
||||
def log_section_start(self, section_title: str, section_index: int):
|
||||
self.log("section_start", "generating",
|
||||
{"message": t('report.sectionStart', title=section_title)},
|
||||
section_title=section_title, section_index=section_index)
|
||||
|
||||
def log_react_thought(self, section_title: str, section_index: int,
|
||||
iteration: int, thought: str):
|
||||
self.log("react_thought", "generating", {
|
||||
"iteration": iteration, "thought": thought,
|
||||
"message": t('report.reactThought', iteration=iteration),
|
||||
}, section_title=section_title, section_index=section_index)
|
||||
|
||||
def log_tool_call(self, section_title: str, section_index: int,
|
||||
tool_name: str, parameters: Dict[str, Any], iteration: int):
|
||||
self.log("tool_call", "generating", {
|
||||
"iteration": iteration, "tool_name": tool_name, "parameters": parameters,
|
||||
"message": t('report.toolCall', toolName=tool_name),
|
||||
}, section_title=section_title, section_index=section_index)
|
||||
|
||||
def log_tool_result(self, section_title: str, section_index: int,
|
||||
tool_name: str, result: str, iteration: int):
|
||||
self.log("tool_result", "generating", {
|
||||
"iteration": iteration, "tool_name": tool_name,
|
||||
"result": result, "result_length": len(result),
|
||||
"message": t('report.toolResult', toolName=tool_name),
|
||||
}, section_title=section_title, section_index=section_index)
|
||||
|
||||
def log_llm_response(self, section_title: str, section_index: int,
|
||||
response: str, iteration: int,
|
||||
has_tool_calls: bool, has_final_answer: bool):
|
||||
self.log("llm_response", "generating", {
|
||||
"iteration": iteration, "response": response,
|
||||
"response_length": len(response),
|
||||
"has_tool_calls": has_tool_calls, "has_final_answer": has_final_answer,
|
||||
"message": t('report.llmResponse', hasToolCalls=has_tool_calls,
|
||||
hasFinalAnswer=has_final_answer),
|
||||
}, section_title=section_title, section_index=section_index)
|
||||
|
||||
def log_section_content(self, section_title: str, section_index: int,
|
||||
content: str, tool_calls_count: int):
|
||||
self.log("section_content", "generating", {
|
||||
"content": content, "content_length": len(content),
|
||||
"tool_calls_count": tool_calls_count,
|
||||
"message": t('report.sectionContentDone', title=section_title),
|
||||
}, section_title=section_title, section_index=section_index)
|
||||
|
||||
def log_section_full_complete(self, section_title: str, section_index: int,
|
||||
full_content: str):
|
||||
self.log("section_complete", "generating", {
|
||||
"content": full_content, "content_length": len(full_content),
|
||||
"message": t('report.sectionComplete', title=section_title),
|
||||
}, section_title=section_title, section_index=section_index)
|
||||
|
||||
def log_report_complete(self, total_sections: int, total_time_seconds: float):
|
||||
self.log("report_complete", "completed", {
|
||||
"total_sections": total_sections,
|
||||
"total_time_seconds": round(total_time_seconds, 2),
|
||||
"message": t('report.reportComplete'),
|
||||
})
|
||||
|
||||
def log_error(self, error_message: str, stage: str, section_title: str = None):
|
||||
self.log("error", stage, {
|
||||
"error": error_message,
|
||||
"message": t('report.errorOccurred', error=error_message),
|
||||
}, section_title=section_title)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# ReportConsoleLogger — console_log.txt
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class ReportConsoleLogger:
|
||||
def __init__(self, report_id: str):
|
||||
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):
|
||||
os.makedirs(os.path.dirname(self.log_file_path), exist_ok=True)
|
||||
|
||||
def _setup_file_handler(self):
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)s: %(message)s', datefmt='%H:%M:%S'
|
||||
)
|
||||
self._file_handler = logging.FileHandler(
|
||||
self.log_file_path, mode='a', encoding='utf-8'
|
||||
)
|
||||
self._file_handler.setLevel(logging.INFO)
|
||||
self._file_handler.setFormatter(formatter)
|
||||
|
||||
for name in ('foresight.report_agent', 'foresight.zep_tools'):
|
||||
target = logging.getLogger(name)
|
||||
if self._file_handler not in target.handlers:
|
||||
target.addHandler(self._file_handler)
|
||||
|
||||
def close(self):
|
||||
if not self._file_handler:
|
||||
return
|
||||
for name in ('foresight.report_agent', 'foresight.zep_tools'):
|
||||
target = logging.getLogger(name)
|
||||
if self._file_handler in target.handlers:
|
||||
target.removeHandler(self._file_handler)
|
||||
self._file_handler.close()
|
||||
self._file_handler = None
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# ReportManager — 文件持久化
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class ReportManager:
|
||||
REPORTS_DIR = os.path.join(Config.UPLOAD_FOLDER, 'reports')
|
||||
|
||||
@classmethod
|
||||
def _ensure_reports_dir(cls):
|
||||
os.makedirs(cls.REPORTS_DIR, exist_ok=True)
|
||||
|
||||
@classmethod
|
||||
def _get_report_folder(cls, report_id: str) -> str:
|
||||
return os.path.join(cls.REPORTS_DIR, report_id)
|
||||
|
||||
@classmethod
|
||||
def _ensure_report_folder(cls, report_id: str) -> str:
|
||||
folder = cls._get_report_folder(report_id)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
return folder
|
||||
|
||||
@classmethod
|
||||
def _get_report_path(cls, report_id: str) -> str:
|
||||
return os.path.join(cls._get_report_folder(report_id), "meta.json")
|
||||
|
||||
@classmethod
|
||||
def _get_report_markdown_path(cls, report_id: str) -> str:
|
||||
return os.path.join(cls._get_report_folder(report_id), "full_report.md")
|
||||
|
||||
@classmethod
|
||||
def _get_outline_path(cls, report_id: str) -> str:
|
||||
return os.path.join(cls._get_report_folder(report_id), "outline.json")
|
||||
|
||||
@classmethod
|
||||
def _get_progress_path(cls, report_id: str) -> str:
|
||||
return os.path.join(cls._get_report_folder(report_id), "progress.json")
|
||||
|
||||
@classmethod
|
||||
def _get_section_path(cls, report_id: str, section_index: int) -> str:
|
||||
return os.path.join(cls._get_report_folder(report_id), f"section_{section_index:02d}.md")
|
||||
|
||||
@classmethod
|
||||
def _get_agent_log_path(cls, report_id: str) -> str:
|
||||
return os.path.join(cls._get_report_folder(report_id), "agent_log.jsonl")
|
||||
|
||||
@classmethod
|
||||
def _get_console_log_path(cls, report_id: str) -> str:
|
||||
return os.path.join(cls._get_report_folder(report_id), "console_log.txt")
|
||||
|
||||
# ── Infographic ──
|
||||
|
||||
@classmethod
|
||||
def _get_infographic_path(cls, report_id: str) -> str:
|
||||
return os.path.join(cls._get_report_folder(report_id), "infographic_data.json")
|
||||
|
||||
@classmethod
|
||||
def save_infographic(cls, report_id: str, data: Dict[str, Any]) -> None:
|
||||
path = cls._get_infographic_path(report_id)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def get_infographic(cls, report_id: str) -> Optional[Dict[str, Any]]:
|
||||
path = cls._get_infographic_path(report_id)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
# ── Console Log ──
|
||||
|
||||
@classmethod
|
||||
def get_console_log(cls, report_id: str, from_line: int = 0) -> Dict[str, Any]:
|
||||
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:
|
||||
logs.append(line.rstrip('\n\r'))
|
||||
|
||||
return {"logs": logs, "total_lines": total_lines, "from_line": from_line, "has_more": False}
|
||||
|
||||
@classmethod
|
||||
def get_console_log_stream(cls, report_id: str) -> List[str]:
|
||||
return cls.get_console_log(report_id, from_line=0)["logs"]
|
||||
|
||||
# ── Agent Log ──
|
||||
|
||||
@classmethod
|
||||
def get_agent_log(cls, report_id: str, from_line: int = 0) -> Dict[str, Any]:
|
||||
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:
|
||||
logs.append(json.loads(line.strip()))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return {"logs": logs, "total_lines": total_lines, "from_line": from_line, "has_more": False}
|
||||
|
||||
@classmethod
|
||||
def get_agent_log_stream(cls, report_id: str) -> List[Dict[str, Any]]:
|
||||
return cls.get_agent_log(report_id, from_line=0)["logs"]
|
||||
|
||||
# ── Outline ──
|
||||
|
||||
@classmethod
|
||||
def save_outline(cls, report_id: str, outline: ReportOutline) -> None:
|
||||
path = cls._get_outline_path(report_id)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(outline.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── Section ──
|
||||
|
||||
@classmethod
|
||||
def save_section(cls, report_id: str, section_index: int, section: ReportSection) -> None:
|
||||
path = cls._get_section_path(report_id, section_index)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(f"## {section.title}\n\n{section.content}")
|
||||
|
||||
# ── Progress ──
|
||||
|
||||
@classmethod
|
||||
def update_progress(cls, report_id: str, status: str, progress: int,
|
||||
message: str, **kwargs) -> None:
|
||||
path = cls._get_progress_path(report_id)
|
||||
data = {"status": status, "progress": progress, "message": message}
|
||||
data.update(kwargs)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# ── Full Report ──
|
||||
|
||||
@classmethod
|
||||
def save_report(cls, report: Report) -> None:
|
||||
cls._ensure_report_folder(report.report_id)
|
||||
path = cls._get_report_path(report.report_id)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(report.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def get_report(cls, report_id: str) -> Optional[Report]:
|
||||
path = cls._get_report_path(report_id)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
outline = None
|
||||
if data.get('outline'):
|
||||
sections = [ReportSection(**s) for s in data['outline'].get('sections', [])]
|
||||
outline = ReportOutline(
|
||||
title=data['outline']['title'],
|
||||
summary=data['outline']['summary'],
|
||||
sections=sections,
|
||||
)
|
||||
|
||||
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=data.get('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]:
|
||||
cls._ensure_reports_dir()
|
||||
for name in sorted(os.listdir(cls.REPORTS_DIR), reverse=True):
|
||||
meta_path = os.path.join(cls.REPORTS_DIR, name, "meta.json")
|
||||
if not os.path.exists(meta_path):
|
||||
continue
|
||||
try:
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if data.get('simulation_id') == simulation_id and data.get('status') == 'completed':
|
||||
return cls.get_report(name)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def assemble_full_report(cls, report_id: str, outline: ReportOutline) -> str:
|
||||
md = f"# {outline.title}\n\n"
|
||||
md += f"> {outline.summary}\n\n"
|
||||
for i, section in enumerate(outline.sections, 1):
|
||||
section_path = cls._get_section_path(report_id, i)
|
||||
if os.path.exists(section_path):
|
||||
with open(section_path, 'r', encoding='utf-8') as f:
|
||||
md += f.read() + "\n\n"
|
||||
else:
|
||||
md += f"## {section.title}\n\n{section.content}\n\n"
|
||||
return md
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
"""
|
||||
Report Agent Prompt 模板常量
|
||||
|
||||
从 report_agent.py 中提取,并进行了以下改动:
|
||||
1. 新增「数据硬性要求」规则,强制引用具体数字和 Agent 原文
|
||||
2. 新增 simulation_analytics 工具描述
|
||||
3. 重写 plan prompt 强调回答用户核心问题
|
||||
"""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 工具描述
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
TOOL_DESC_SIMULATION_ANALYTICS = """\
|
||||
【模拟数据统计分析 - 直接读取原始模拟行为数据】
|
||||
直接从模拟行为日志中提取统计数据和真实Agent发言,**不经过知识图谱转换**,数据更原始、更精确。
|
||||
|
||||
可用查询类型(query_type参数):
|
||||
- overview_stats: 模拟全局统计(总帖子数、互动数、Agent活跃度、各平台分布等)
|
||||
- top_posts: 按互动量排名的Top帖子,含原文内容
|
||||
- agent_quotes: 真实Agent发言摘录(正面/负面/随机),可直接引用
|
||||
- action_distribution: 按类型/平台/轮次的动作分布
|
||||
- engagement_metrics: 参与度指标(总互动、平均参与率、Top Agent)
|
||||
- sentiment_breakdown: 情感分布(正面/负面/中性比例)
|
||||
- infographic_data: 聚合所有数据的完整信息图
|
||||
|
||||
【使用场景】
|
||||
- 需要获取具体数字(如"47.3%的Agent"、"128条帖子")
|
||||
- 需要引用Agent的原始发言内容(verbatim quote)
|
||||
- 需要统计数据支撑报告论点
|
||||
|
||||
【返回内容】
|
||||
- 结构化JSON,包含具体数字和Agent原文"""
|
||||
|
||||
TOOL_DESC_INSIGHT_FORGE = """\
|
||||
【深度洞察检索 - 强大的图谱检索工具】
|
||||
从知识图谱中检索模拟世界的深层关系和语义信息。它会:
|
||||
1. 自动将你的问题分解为多个子问题
|
||||
2. 从多个维度检索模拟图谱中的信息
|
||||
3. 整合语义搜索、实体分析、关系链追踪的结果
|
||||
4. 返回最全面、最深度的图谱检索内容
|
||||
|
||||
【使用场景】
|
||||
- 需要深入分析某个话题的因果关系
|
||||
- 需要了解事件的发展脉络和关联
|
||||
- 需要获取支撑报告章节的深度素材
|
||||
|
||||
【返回内容】
|
||||
- 相关事实原文(可直接引用)
|
||||
- 核心实体洞察
|
||||
- 关系链分析"""
|
||||
|
||||
TOOL_DESC_PANORAMA_SEARCH = """\
|
||||
【广度搜索 - 获取全貌视图】
|
||||
获取知识图谱中的完整全貌,了解模拟世界的整体结构。它会:
|
||||
1. 获取所有相关节点和关系
|
||||
2. 区分当前有效的事实和历史/过期的事实
|
||||
3. 帮助你了解舆情是如何演变的
|
||||
|
||||
【使用场景】
|
||||
- 需要了解事件的完整发展脉络
|
||||
- 需要对比不同阶段的舆情变化
|
||||
- 需要获取全面的实体和关系信息"""
|
||||
|
||||
TOOL_DESC_QUICK_SEARCH = """\
|
||||
【简单搜索 - 快速检索】
|
||||
轻量级的快速检索工具,适合简单、直接的信息查询。
|
||||
|
||||
【使用场景】
|
||||
- 需要快速查找某个具体信息
|
||||
- 需要验证某个事实"""
|
||||
|
||||
TOOL_DESC_INTERVIEW_AGENTS = """\
|
||||
【深度采访 - 真实Agent采访(双平台)】
|
||||
调用OASIS模拟环境的采访API,对模拟Agent进行真实采访。
|
||||
默认在Twitter和Reddit两个平台同时采访。
|
||||
|
||||
【使用场景】
|
||||
- 需要从不同角色视角了解事件看法
|
||||
- 需要收集多方意见和立场
|
||||
- 需要获取模拟Agent的第一人称回答
|
||||
|
||||
【重要】需要OASIS模拟环境正在运行才能使用此功能!"""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 大纲规划 Prompt
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
PLAN_SYSTEM_PROMPT = """\
|
||||
你是一个「未来预测报告」的撰写专家,拥有对模拟世界的「上帝视角」——你可以洞察模拟中每一位Agent的行为、言论和互动。
|
||||
|
||||
【核心理念】
|
||||
我们构建了一个模拟世界,并向其中注入了特定的「模拟需求」作为变量。模拟世界的演化结果,就是对未来可能发生情况的预测。你正在观察的不是"实验数据",而是"未来的预演"。
|
||||
|
||||
【关键原则:以用户需求为中心】
|
||||
报告必须**直接回答**用户在「模拟需求」中提出的核心问题。
|
||||
在规划章节之前,先分析模拟需求,提取用户真正关心的 2-3 个核心问题,
|
||||
然后围绕这些核心问题组织报告章节,确保每个章节都在回答用户的具体关切。
|
||||
|
||||
【数据硬性要求】
|
||||
1. 每个章节描述必须包含预期要呈现的具体数据维度
|
||||
2. 报告必须直接用模拟数据回答用户的核心问题,给出明确判断(好/坏、顺利/不顺利、具体数字)
|
||||
3. 严禁规划"综合性分析"这种模糊章节,必须具体到"XX维度的数据验证"
|
||||
4. 所有百分比、数量、比例必须基于模拟数据,不可凭空编造
|
||||
|
||||
【你的任务】
|
||||
1. 先从「模拟需求」中提取用户的核心关注点
|
||||
2. 撰写一份「未来预测报告」,**直接回答**这些核心关注点
|
||||
3. 用模拟世界中的Agent行为和互动数据作为证据支撑
|
||||
|
||||
【报告定位】
|
||||
- ✅ 这是一份基于模拟的未来预测报告,揭示"如果这样,未来会怎样"
|
||||
- ✅ 聚焦于预测结果:事件走向、群体反应、涌现现象、潜在风险
|
||||
- ✅ 模拟世界中的Agent言行就是对未来人群行为的预测
|
||||
- ✅ 必须直接回答用户在模拟需求中提出的具体问题
|
||||
- ❌ 不是泛泛而谈的舆情综述
|
||||
- ❌ 不能回避用户的核心问题,给出笼统的"需要进一步分析"
|
||||
|
||||
【章节数量限制】
|
||||
- 最少2个章节,最多5个章节
|
||||
- 每个章节必须有明确的数据聚焦方向
|
||||
|
||||
请输出JSON格式的报告大纲:
|
||||
{
|
||||
"title": "报告标题",
|
||||
"summary": "报告摘要(一句话概括核心预测发现)",
|
||||
"sections": [
|
||||
{"title": "章节标题", "description": "章节内容描述(含预期数据维度)"}
|
||||
]
|
||||
}"""
|
||||
|
||||
PLAN_USER_PROMPT_TEMPLATE = """\
|
||||
【预测场景设定】
|
||||
模拟需求:{simulation_requirement}
|
||||
|
||||
【模拟世界规模】
|
||||
- 参与模拟的实体数量: {total_nodes}
|
||||
- 实体间产生的关系数量: {total_edges}
|
||||
- 实体类型分布: {entity_types}
|
||||
- 活跃Agent数量: {total_entities}
|
||||
|
||||
【模拟行为数据概览】
|
||||
- 模拟总轮次: {total_rounds}
|
||||
- 参与Agent数: {total_agents}
|
||||
- Twitter帖子数: {twitter_posts}
|
||||
- Reddit帖子数: {reddit_posts}
|
||||
- 总互动数: {total_engagement}
|
||||
- 正面情感比例: {positive_ratio}%
|
||||
- 负面情感比例: {negative_ratio}%
|
||||
|
||||
【模拟预测到的部分未来事实样本】
|
||||
{related_facts_json}
|
||||
|
||||
请以「上帝视角」审视这个未来预演:
|
||||
|
||||
【重要】首先从「模拟需求」中提取用户最关心的 2-3 个核心问题,然后:
|
||||
1. 直接回答这些核心问题——基于模拟数据给出明确判断
|
||||
2. 如果存在问题或风险,明确指出具体位置和原因
|
||||
3. 用模拟世界中的Agent行为数据作为证据
|
||||
4. 每个章节聚焦一个核心问题或一个数据维度
|
||||
|
||||
设计最合适的报告章节结构(2-5个章节),确保报告**直接回应**用户关切。"""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 章节生成 Prompt
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
SECTION_SYSTEM_PROMPT_TEMPLATE = """\
|
||||
你是一个「未来预测报告」的撰写专家,正在撰写报告的一个章节。
|
||||
|
||||
报告标题: {report_title}
|
||||
报告摘要: {report_summary}
|
||||
预测场景(模拟需求): {simulation_requirement}
|
||||
|
||||
当前要撰写的章节: {section_title}
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
【数据硬性要求 — 违反即失败】
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
1. **具体数字**:每个论点必须包含具体数字,例如:
|
||||
- ✅ "**47.3%**的Agent(共**14位**)在讨论中表达了正面看法"
|
||||
- ✅ "共产生**128条**帖子,其中**Twitter平台89条**,**Reddit平台39条**"
|
||||
- ❌ "一定比例的Agent"、"部分用户"、"多数人表示"(模糊表述,禁止使用)
|
||||
|
||||
2. **Agent原文引用**:每个章节至少引用5条Agent原始发言,格式:
|
||||
> "Agent的原始发言内容..." ——AgentName(平台,Round N)
|
||||
正面和负面观点**都必须包含**,不可只报喜不报忧。
|
||||
|
||||
3. **禁止回避**:
|
||||
- ❌ "需要进一步分析"
|
||||
- ❌ "值得持续关注"
|
||||
- ❌ "具体情况因人而异"
|
||||
- ❌ "预计会有一定比例"
|
||||
以上措辞**严禁出现**。如果数据不足,直接说明缺什么数据。
|
||||
|
||||
4. **simulation_analytics 工具必须调用**:
|
||||
至少调用1次 simulation_analytics 获取硬数据(overview_stats, agent_quotes 等),
|
||||
然后在正文中引用其中的具体数字。
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
【核心理念】
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
模拟世界是对未来的预演。模拟中Agent的行为和互动,就是对未来人群行为的预测。
|
||||
你的任务是揭示在设定条件下未来发生了什么,预测各类人群是如何反应和行动的。
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
【格式规范 - 极其重要!】
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
- ❌ 禁止在章节内使用任何 Markdown 标题(#、##、###、####)
|
||||
- ❌ 禁止在内容开头添加章节主标题
|
||||
- ✅ 章节标题由系统自动添加,你只需撰写纯正文
|
||||
- ✅ 使用**粗体**标记重点数据和关键词
|
||||
- ✅ 使用引用块(>)展示Agent原文
|
||||
|
||||
【引用格式 — 必须单独成段】
|
||||
```
|
||||
分析发现多数Agent持正面态度。
|
||||
|
||||
> "这门课内容很充实,学到了很多实用技巧。" ——Student_Alice(Twitter,Round 2)
|
||||
|
||||
> "价格有点贵但内容对得起这个价格。" ——Professional_Bob(Reddit,Round 3)
|
||||
|
||||
但也有Agent表达了不同意见:
|
||||
|
||||
> "课程节奏太快,基础薄弱的人可能跟不上。" ——Learner_Carol(Twitter,Round 4)
|
||||
```
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
【可用检索工具】(每章节调用3-5次)
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
{tools_description}
|
||||
|
||||
【工具使用策略】
|
||||
1. **第一步**:调用 simulation_analytics 获取硬数据(overview_stats, agent_quotes, sentiment_breakdown)
|
||||
2. **第二步**:调用图谱工具(insight_forge, panorama_search)获取深层关系和洞察
|
||||
3. **第三步**:如需更多素材,调用 quick_search 或再次调用 analytics
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
【工作流程】
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
每次回复你只能做以下两件事之一:
|
||||
|
||||
选项A - 调用工具:
|
||||
输出你的思考,然后用以下格式调用一个工具:
|
||||
edisnormal
|
||||
{{"name": "工具名称", "parameters": {{"参数名": "参数值"}}}}
|
||||
edisnormal
|
||||
系统会执行工具并把结果返回给你。
|
||||
|
||||
选项B - 输出最终内容:
|
||||
当你已通过工具获取了足够信息,以 "Final Answer:" 开头输出章节内容。
|
||||
|
||||
⚠️ 严格禁止:
|
||||
- 禁止在一次回复中同时包含工具调用和 Final Answer
|
||||
- 禁止自己编造工具返回结果
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
【章节内容要求】
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
1. 内容必须基于工具检索到的模拟数据
|
||||
2. 大量引用Agent原文来展示模拟效果
|
||||
3. 使用**粗体**标记关键数字
|
||||
4. 保持与其他章节的逻辑连贯性
|
||||
5. 避免与已完成章节重复描述相同信息
|
||||
6. 再次强调:不要添加任何标题!用**粗体**代替小节标题"""
|
||||
|
||||
SECTION_USER_PROMPT_TEMPLATE = """\
|
||||
已完成的章节内容(请仔细阅读,避免重复):
|
||||
{previous_content}
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
【当前任务】撰写章节: {section_title}
|
||||
═══════════════════════════════════════════════════════════════
|
||||
|
||||
【重要提醒】
|
||||
1. 仔细阅读上方已完成的章节,避免重复
|
||||
2. 第一步先调用 simulation_analytics 获取硬数据
|
||||
3. 然后调用图谱工具获取深度洞察
|
||||
4. 必须引用具体数字和Agent原文
|
||||
5. 禁止使用模糊表述("一定比例"、"部分用户"等)
|
||||
|
||||
【格式警告】
|
||||
- ❌ 不要写任何标题
|
||||
- ❌ 不要写"{section_title}"作为开头
|
||||
- ✅ 直接写正文,用**粗体**标记数据
|
||||
|
||||
请开始工作。"""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# ReACT 循环内消息模板
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
REACT_OBSERVATION_TEMPLATE = """\
|
||||
Observation(检索结果):
|
||||
|
||||
═══ 工具 {tool_name} 返回 ═══
|
||||
{result}
|
||||
|
||||
═══════════════════════════════════════════════════════════════
|
||||
已调用工具 {tool_calls_count}/{max_tool_calls} 次(已用: {used_tools_str}){unused_hint}
|
||||
- 如果信息充分:以 "Final Answer:" 开头输出章节内容(必须引用具体数字和Agent原文)
|
||||
- 如果需要更多信息:调用一个工具继续检索
|
||||
|
||||
{analytics_hint}═══════════════════════════════════════════════════════════════"""
|
||||
|
||||
REACT_INSUFFICIENT_TOOLS_MSG = (
|
||||
"【注意】你只调用了{tool_calls_count}次工具,至少需要{min_tool_calls}次。"
|
||||
"请再调用工具获取更多模拟数据,然后再输出 Final Answer。{unused_hint}"
|
||||
)
|
||||
|
||||
REACT_INSUFFICIENT_TOOLS_MSG_ALT = (
|
||||
"当前只调用了 {tool_calls_count} 次工具,至少需要 {min_tool_calls} 次。"
|
||||
"请调用工具获取模拟数据。{unused_hint}"
|
||||
)
|
||||
|
||||
REACT_TOOL_LIMIT_MSG = (
|
||||
"工具调用次数已达上限({tool_calls_count}/{max_tool_calls}),不能再调用工具。"
|
||||
'请立即基于已获取的信息,以 "Final Answer:" 开头输出章节内容。'
|
||||
"记得引用具体数字和Agent原文。"
|
||||
)
|
||||
|
||||
REACT_UNUSED_TOOLS_HINT = "\n💡 你还没有使用过: {unused_list},建议尝试不同工具获取多角度信息"
|
||||
|
||||
REACT_FORCE_FINAL_MSG = (
|
||||
"已达到工具调用限制,请直接输出 Final Answer: 并生成章节内容。"
|
||||
"务必引用已获取数据中的具体数字和Agent原文。"
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Chat Prompt
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
CHAT_SYSTEM_PROMPT_TEMPLATE = """\
|
||||
你是一个简洁高效的模拟预测助手。
|
||||
|
||||
【背景】
|
||||
预测条件: {simulation_requirement}
|
||||
|
||||
【已生成的分析报告】
|
||||
{report_content}
|
||||
|
||||
【规则】
|
||||
1. 优先基于上述报告内容回答问题
|
||||
2. 直接回答问题,避免冗长的思考论述
|
||||
3. 仅在报告内容不足以回答时,才调用工具检索更多数据
|
||||
4. 回答要简洁、清晰、有条理
|
||||
5. 引用具体数字和Agent原文支撑回答
|
||||
|
||||
【可用工具】(仅在需要时使用,最多调用1-2次)
|
||||
{tools_description}
|
||||
|
||||
【工具调用格式】
|
||||
edisnormal
|
||||
{{"name": "工具名称", "parameters": {{"参数名": "参数值"}}}}
|
||||
edisnormal
|
||||
|
||||
【回答风格】
|
||||
- 简洁直接
|
||||
- 使用 > 格式引用关键内容
|
||||
- 优先给出结论,再解释原因"""
|
||||
|
||||
CHAT_OBSERVATION_SUFFIX = "\n\n请简洁回答问题,引用具体数字。"
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
"""
|
||||
Simulation Analytics Service
|
||||
直接读取 actions.jsonl 提供模拟行为统计数据和真实 Agent 发言内容。
|
||||
用于报告生成时获取具体数字和可引用的原文。
|
||||
|
||||
数据来源:SimulationRunner.get_all_actions() → actions.jsonl
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
from typing import Dict, Any, List, Optional
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from ..utils.logger import get_logger
|
||||
from .simulation_runner import SimulationRunner
|
||||
|
||||
logger = get_logger('foresight.simulation_analytics')
|
||||
|
||||
# 简单的情感关键词列表(避免 LLM 依赖)
|
||||
POSITIVE_KEYWORDS = {
|
||||
'好', '棒', '赞', '喜欢', '支持', '期待', '感谢', '满意', '优秀', '不错',
|
||||
'开心', '高兴', '推荐', '值得', '信任', '认同', '赞同', ' helpful', 'agree',
|
||||
'great', 'good', 'love', 'excellent', 'amazing', 'awesome', 'nice', 'best',
|
||||
'like', 'support', 'happy', 'thank', 'recommend', 'worth',
|
||||
}
|
||||
|
||||
NEGATIVE_KEYWORDS = {
|
||||
'差', '烂', '坑', '骗', '垃圾', '失望', '不满', '退款', '投诉', '问题',
|
||||
'糟糕', '难过', '愤怒', '不推荐', '浪费', '后悔', '差评', '反感', 'bad',
|
||||
'terrible', 'worst', 'hate', 'awful', 'disappoint', 'waste', 'refund',
|
||||
'complaint', 'angry', 'frustrat', 'annoy', 'poor', 'fail', 'scam',
|
||||
}
|
||||
|
||||
# 有文本内容的 action types(可提取引用)
|
||||
CONTENT_ACTION_TYPES = {'CREATE_POST', 'CREATE_COMMENT', 'QUOTE_POST'}
|
||||
|
||||
# 互动类型的 action types
|
||||
ENGAGEMENT_ACTION_TYPES = {'LIKE_POST', 'DISLIKE_POST', 'REPOST', 'LIKE_COMMENT', 'DISLIKE_COMMENT'}
|
||||
|
||||
|
||||
class SimulationAnalyticsService:
|
||||
"""
|
||||
模拟数据分析服务
|
||||
|
||||
直接读取 actions.jsonl 提供统计数据,不经过 Graphiti 知识图谱。
|
||||
"""
|
||||
|
||||
def get_overview_stats(self, simulation_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
模拟全局统计概览
|
||||
|
||||
Returns:
|
||||
{
|
||||
total_agents, total_actions, total_rounds,
|
||||
twitter_posts, reddit_posts, total_posts,
|
||||
total_engagement, avg_activity_per_agent,
|
||||
action_type_counts: {action_type: count},
|
||||
platform_breakdown: {platform: count},
|
||||
}
|
||||
"""
|
||||
actions = SimulationRunner.get_all_actions(simulation_id, limit=50000)
|
||||
if not actions:
|
||||
return self._empty_overview()
|
||||
|
||||
agent_ids = set()
|
||||
rounds = set()
|
||||
action_type_counts = Counter()
|
||||
platform_counts = Counter()
|
||||
posts = 0
|
||||
engagement = 0
|
||||
|
||||
for action in actions:
|
||||
agent_ids.add(action.agent_id)
|
||||
rounds.add(action.round_num)
|
||||
action_type_counts[action.action_type] += 1
|
||||
platform_counts[action.platform] += 1
|
||||
|
||||
if action.action_type == 'CREATE_POST':
|
||||
posts += 1
|
||||
elif action.action_type in ENGAGEMENT_ACTION_TYPES:
|
||||
engagement += 1
|
||||
|
||||
total_agents = len(agent_ids)
|
||||
total_actions = len(actions)
|
||||
|
||||
return {
|
||||
'total_agents': total_agents,
|
||||
'total_actions': total_actions,
|
||||
'total_rounds': max(rounds) + 1 if rounds else 0,
|
||||
'total_posts': posts,
|
||||
'twitter_posts': sum(1 for a in actions if a.action_type == 'CREATE_POST' and a.platform == 'twitter'),
|
||||
'reddit_posts': sum(1 for a in actions if a.action_type == 'CREATE_POST' and a.platform == 'reddit'),
|
||||
'total_engagement': engagement,
|
||||
'avg_activity_per_agent': round(total_actions / max(total_agents, 1), 1),
|
||||
'action_type_counts': dict(action_type_counts),
|
||||
'platform_breakdown': dict(platform_counts),
|
||||
}
|
||||
|
||||
def get_top_posts(
|
||||
self,
|
||||
simulation_id: str,
|
||||
n: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取最活跃的帖子(按该帖获得的互动数排序)
|
||||
|
||||
Returns:
|
||||
[{agent_name, content, platform, round_num, engagement_count}]
|
||||
"""
|
||||
actions = SimulationRunner.get_all_actions(simulation_id, limit=50000)
|
||||
if not actions:
|
||||
return []
|
||||
|
||||
# 1. 收集所有原创帖
|
||||
posts = {}
|
||||
for action in actions:
|
||||
if action.action_type == 'CREATE_POST':
|
||||
content = action.action_args.get('content', '')
|
||||
key = (action.agent_name, content[:100], action.platform)
|
||||
posts[key] = {
|
||||
'agent_name': action.agent_name,
|
||||
'content': content,
|
||||
'platform': action.platform,
|
||||
'round_num': action.round_num,
|
||||
'engagement_count': 0,
|
||||
}
|
||||
|
||||
# 2. 统计每个帖的互动(通过被引用的内容匹配)
|
||||
post_contents = {k: v['content'][:80] for k, v in posts.items()}
|
||||
for action in actions:
|
||||
if action.action_type in ('LIKE_POST', 'REPOST', 'CREATE_COMMENT', 'QUOTE_POST'):
|
||||
referenced = (
|
||||
action.action_args.get('post_content', '')
|
||||
or action.action_args.get('quoted_content', '')
|
||||
)
|
||||
if not referenced:
|
||||
continue
|
||||
ref_prefix = referenced[:80]
|
||||
for key, pc in post_contents.items():
|
||||
if ref_prefix and pc and (ref_prefix in pc or pc in ref_prefix):
|
||||
posts[key]['engagement_count'] += 1
|
||||
break
|
||||
|
||||
# 3. 按互动量排序
|
||||
sorted_posts = sorted(posts.values(), key=lambda x: x['engagement_count'], reverse=True)
|
||||
return sorted_posts[:n]
|
||||
|
||||
def get_agent_quotes(
|
||||
self,
|
||||
simulation_id: str,
|
||||
n_positive: int = 5,
|
||||
n_negative: int = 5,
|
||||
n_random: int = 5,
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
提取真实 Agent 发言摘录,按情感分类
|
||||
|
||||
Returns:
|
||||
{
|
||||
positive: [{agent_name, quote, platform, action_type, round_num}],
|
||||
negative: [...],
|
||||
random: [...],
|
||||
}
|
||||
"""
|
||||
actions = SimulationRunner.get_all_actions(simulation_id, limit=50000)
|
||||
if not actions:
|
||||
return {'positive': [], 'negative': [], 'random': []}
|
||||
|
||||
# 提取有文本内容的 action
|
||||
content_actions = []
|
||||
for action in actions:
|
||||
if action.action_type not in CONTENT_ACTION_TYPES:
|
||||
continue
|
||||
content = action.action_args.get('content', '')
|
||||
if not content or len(content.strip()) < 10:
|
||||
continue
|
||||
content_actions.append({
|
||||
'agent_name': action.agent_name,
|
||||
'quote': content.strip(),
|
||||
'platform': action.platform,
|
||||
'action_type': action.action_type,
|
||||
'round_num': action.round_num,
|
||||
})
|
||||
|
||||
if not content_actions:
|
||||
return {'positive': [], 'negative': [], 'random': []}
|
||||
|
||||
# 按情感分类
|
||||
positive = []
|
||||
negative = []
|
||||
neutral = []
|
||||
|
||||
for item in content_actions:
|
||||
text_lower = item['quote'].lower()
|
||||
pos_score = sum(1 for kw in POSITIVE_KEYWORDS if kw in text_lower)
|
||||
neg_score = sum(1 for kw in NEGATIVE_KEYWORDS if kw in text_lower)
|
||||
|
||||
if pos_score > neg_score:
|
||||
positive.append(item)
|
||||
elif neg_score > pos_score:
|
||||
negative.append(item)
|
||||
else:
|
||||
neutral.append(item)
|
||||
|
||||
# 采样
|
||||
result = {
|
||||
'positive': self._deduplicate_and_sample(positive, n_positive),
|
||||
'negative': self._deduplicate_and_sample(negative, n_negative),
|
||||
'random': self._deduplicate_and_sample(
|
||||
neutral if neutral else content_actions, n_random
|
||||
),
|
||||
}
|
||||
return result
|
||||
|
||||
def get_action_distribution(self, simulation_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
按类型/平台/轮次的动作分布
|
||||
|
||||
Returns:
|
||||
{
|
||||
by_type: {action_type: count},
|
||||
by_platform: {platform: {action_type: count}},
|
||||
by_round: [{round_num, twitter, reddit, total}],
|
||||
}
|
||||
"""
|
||||
actions = SimulationRunner.get_all_actions(simulation_id, limit=50000)
|
||||
if not actions:
|
||||
return {'by_type': {}, 'by_platform': {}, 'by_round': []}
|
||||
|
||||
by_type = Counter()
|
||||
by_platform: Dict[str, Counter] = defaultdict(Counter)
|
||||
round_data: Dict[int, Dict] = {}
|
||||
|
||||
for action in actions:
|
||||
by_type[action.action_type] += 1
|
||||
by_platform[action.platform][action.action_type] += 1
|
||||
|
||||
r = action.round_num
|
||||
if r not in round_data:
|
||||
round_data[r] = {'round_num': r, 'twitter': 0, 'reddit': 0, 'total': 0}
|
||||
round_data[r]['total'] += 1
|
||||
if action.platform == 'twitter':
|
||||
round_data[r]['twitter'] += 1
|
||||
else:
|
||||
round_data[r]['reddit'] += 1
|
||||
|
||||
by_round = [round_data[k] for k in sorted(round_data.keys())]
|
||||
|
||||
return {
|
||||
'by_type': dict(by_type),
|
||||
'by_platform': {p: dict(c) for p, c in by_platform.items()},
|
||||
'by_round': by_round,
|
||||
}
|
||||
|
||||
def get_engagement_metrics(self, simulation_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
参与度指标
|
||||
|
||||
Returns:
|
||||
{
|
||||
total_engagement, avg_engagement_per_post,
|
||||
top_agents: [{agent_name, total_actions, posts, engagement}],
|
||||
engagement_by_type: {type: count},
|
||||
}
|
||||
"""
|
||||
actions = SimulationRunner.get_all_actions(simulation_id, limit=50000)
|
||||
if not actions:
|
||||
return {'total_engagement': 0, 'avg_engagement_per_post': 0, 'top_agents': [], 'engagement_by_type': {}}
|
||||
|
||||
total_posts = sum(1 for a in actions if a.action_type == 'CREATE_POST')
|
||||
engagement_by_type = Counter(a.action_type for a in actions if a.action_type in ENGAGEMENT_ACTION_TYPES)
|
||||
total_engagement = sum(engagement_by_type.values())
|
||||
|
||||
# Top agents
|
||||
agent_data: Dict[str, Dict] = {}
|
||||
for action in actions:
|
||||
name = action.agent_name
|
||||
if name not in agent_data:
|
||||
agent_data[name] = {'agent_name': name, 'total_actions': 0, 'posts': 0, 'engagement': 0}
|
||||
agent_data[name]['total_actions'] += 1
|
||||
if action.action_type == 'CREATE_POST':
|
||||
agent_data[name]['posts'] += 1
|
||||
elif action.action_type in ENGAGEMENT_ACTION_TYPES:
|
||||
agent_data[name]['engagement'] += 1
|
||||
|
||||
top_agents = sorted(agent_data.values(), key=lambda x: x['total_actions'], reverse=True)[:10]
|
||||
|
||||
return {
|
||||
'total_engagement': total_engagement,
|
||||
'avg_engagement_per_post': round(total_engagement / max(total_posts, 1), 1),
|
||||
'top_agents': top_agents,
|
||||
'engagement_by_type': dict(engagement_by_type),
|
||||
}
|
||||
|
||||
def get_sentiment_breakdown(self, simulation_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
情感分布(基于关键词的分类)
|
||||
|
||||
Returns:
|
||||
{positive_count, negative_count, neutral_count, positive_ratio, negative_ratio}
|
||||
"""
|
||||
actions = SimulationRunner.get_all_actions(simulation_id, limit=50000)
|
||||
if not actions:
|
||||
return {'positive_count': 0, 'negative_count': 0, 'neutral_count': 0, 'positive_ratio': 0, 'negative_ratio': 0}
|
||||
|
||||
positive_count = 0
|
||||
negative_count = 0
|
||||
neutral_count = 0
|
||||
|
||||
for action in actions:
|
||||
if action.action_type not in CONTENT_ACTION_TYPES:
|
||||
continue
|
||||
content = action.action_args.get('content', '')
|
||||
if not content:
|
||||
continue
|
||||
|
||||
text_lower = content.lower()
|
||||
pos_score = sum(1 for kw in POSITIVE_KEYWORDS if kw in text_lower)
|
||||
neg_score = sum(1 for kw in NEGATIVE_KEYWORDS if kw in text_lower)
|
||||
|
||||
if pos_score > neg_score:
|
||||
positive_count += 1
|
||||
elif neg_score > pos_score:
|
||||
negative_count += 1
|
||||
else:
|
||||
neutral_count += 1
|
||||
|
||||
total = positive_count + negative_count + neutral_count
|
||||
return {
|
||||
'positive_count': positive_count,
|
||||
'negative_count': negative_count,
|
||||
'neutral_count': neutral_count,
|
||||
'positive_ratio': round(positive_count / max(total, 1) * 100, 1),
|
||||
'negative_ratio': round(negative_count / max(total, 1) * 100, 1),
|
||||
'neutral_ratio': round(neutral_count / max(total, 1) * 100, 1),
|
||||
}
|
||||
|
||||
def get_infographic_data(self, simulation_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
聚合所有数据,生成前端信息图所需的完整 JSON
|
||||
|
||||
Returns:
|
||||
{key_metrics, action_distribution, sentiment_breakdown, top_posts, top_agents, timeline}
|
||||
"""
|
||||
overview = self.get_overview_stats(simulation_id)
|
||||
distribution = self.get_action_distribution(simulation_id)
|
||||
sentiment = self.get_sentiment_breakdown(simulation_id)
|
||||
top_posts = self.get_top_posts(simulation_id, n=5)
|
||||
engagement = self.get_engagement_metrics(simulation_id)
|
||||
|
||||
return {
|
||||
'key_metrics': {
|
||||
'total_agents': overview['total_agents'],
|
||||
'total_posts': overview['total_posts'],
|
||||
'total_engagement': overview['total_engagement'],
|
||||
'avg_activity': overview['avg_activity_per_agent'],
|
||||
'total_rounds': overview['total_rounds'],
|
||||
'total_actions': overview['total_actions'],
|
||||
},
|
||||
'action_distribution': distribution,
|
||||
'sentiment_breakdown': sentiment,
|
||||
'top_posts': top_posts,
|
||||
'top_agents': engagement.get('top_agents', [])[:5],
|
||||
'timeline': distribution.get('by_round', []),
|
||||
}
|
||||
|
||||
def get_analytics(self, simulation_id: str, query_type: str, n: int = 10) -> Dict[str, Any]:
|
||||
"""
|
||||
统一入口,供 ReportAgent 工具调用
|
||||
|
||||
Args:
|
||||
simulation_id: 模拟ID
|
||||
query_type: overview_stats | top_posts | agent_quotes | action_distribution | engagement_metrics | sentiment_breakdown | infographic_data
|
||||
n: 返回数量(仅对 top_posts 和 agent_quotes 有效)
|
||||
"""
|
||||
try:
|
||||
if query_type == 'overview_stats':
|
||||
return {'success': True, 'data': self.get_overview_stats(simulation_id)}
|
||||
elif query_type == 'top_posts':
|
||||
return {'success': True, 'data': self.get_top_posts(simulation_id, n=n)}
|
||||
elif query_type == 'agent_quotes':
|
||||
result = self.get_agent_quotes(simulation_id, n_positive=n, n_negative=n, n_random=n)
|
||||
return {'success': True, 'data': result}
|
||||
elif query_type == 'action_distribution':
|
||||
return {'success': True, 'data': self.get_action_distribution(simulation_id)}
|
||||
elif query_type == 'engagement_metrics':
|
||||
return {'success': True, 'data': self.get_engagement_metrics(simulation_id)}
|
||||
elif query_type == 'sentiment_breakdown':
|
||||
return {'success': True, 'data': self.get_sentiment_breakdown(simulation_id)}
|
||||
elif query_type == 'infographic_data':
|
||||
return {'success': True, 'data': self.get_infographic_data(simulation_id)}
|
||||
else:
|
||||
return {'success': False, 'error': f'Unknown query_type: {query_type}'}
|
||||
except Exception as e:
|
||||
logger.error(f'Analytics query failed: {e}')
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
# ── helpers ──
|
||||
|
||||
def _empty_overview(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'total_agents': 0, 'total_actions': 0, 'total_rounds': 0,
|
||||
'total_posts': 0, 'twitter_posts': 0, 'reddit_posts': 0,
|
||||
'total_engagement': 0, 'avg_activity_per_agent': 0,
|
||||
'action_type_counts': {}, 'platform_breakdown': {},
|
||||
}
|
||||
|
||||
def _deduplicate_and_sample(
|
||||
self,
|
||||
items: List[Dict[str, Any]],
|
||||
n: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""去重(按 quote 前80字符)并采样"""
|
||||
seen = set()
|
||||
unique = []
|
||||
for item in items:
|
||||
key = item['quote'][:80]
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(item)
|
||||
|
||||
if len(unique) <= n:
|
||||
return unique
|
||||
return random.sample(unique, n)
|
||||
|
|
@ -77,3 +77,11 @@ export const getReportBySimulation = (simulationId) => {
|
|||
export const checkReportBySimulation = (simulationId) => {
|
||||
return service.get(`/api/report/check/${simulationId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取报告信息图数据
|
||||
* @param {string} reportId
|
||||
*/
|
||||
export const getReportInfographic = (reportId) => {
|
||||
return service.get(`/api/report/${reportId}/infographic`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,429 @@
|
|||
<template>
|
||||
<div class="infographic-dashboard">
|
||||
<!-- Section Title -->
|
||||
<div class="infographic-header">
|
||||
<span class="infographic-badge">Analytics</span>
|
||||
<span class="infographic-title">Simulation Overview</span>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics Cards -->
|
||||
<div class="metrics-row">
|
||||
<div class="metric-card" v-for="card in metricCards" :key="card.label">
|
||||
<span class="metric-value">{{ card.value }}</span>
|
||||
<span class="metric-label">{{ card.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two-column: Action Distribution + Sentiment -->
|
||||
<div class="charts-row">
|
||||
<!-- Action Distribution -->
|
||||
<div class="chart-block">
|
||||
<div class="chart-title">Action Distribution</div>
|
||||
<div class="bar-chart">
|
||||
<div
|
||||
v-for="(bar, idx) in actionBars"
|
||||
:key="idx"
|
||||
class="bar-row"
|
||||
>
|
||||
<span class="bar-label">{{ bar.label }}</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill bar-twitter" :style="{ width: bar.twitterPct + '%' }"></div>
|
||||
<div class="bar-fill bar-reddit" :style="{ width: bar.redditPct + '%', left: bar.twitterPct + '%' }"></div>
|
||||
</div>
|
||||
<span class="bar-count">{{ bar.total }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sentiment Breakdown -->
|
||||
<div class="chart-block">
|
||||
<div class="chart-title">Sentiment Breakdown</div>
|
||||
<div class="sentiment-bars">
|
||||
<div class="sentiment-row" v-if="data.sentiment_breakdown">
|
||||
<div class="sentiment-item positive">
|
||||
<span class="sentiment-dot"></span>
|
||||
<span class="sentiment-label">Positive</span>
|
||||
<span class="sentiment-pct">{{ data.sentiment_breakdown.positive_ratio || 0 }}%</span>
|
||||
</div>
|
||||
<div class="sentiment-item neutral">
|
||||
<span class="sentiment-dot"></span>
|
||||
<span class="sentiment-label">Neutral</span>
|
||||
<span class="sentiment-pct">{{ data.sentiment_breakdown.neutral_ratio || 0 }}%</span>
|
||||
</div>
|
||||
<div class="sentiment-item negative">
|
||||
<span class="sentiment-dot"></span>
|
||||
<span class="sentiment-label">Negative</span>
|
||||
<span class="sentiment-pct">{{ data.sentiment_breakdown.negative_ratio || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sentiment-stacked">
|
||||
<div class="stacked-positive" :style="{ width: (data.sentiment_breakdown?.positive_ratio || 0) + '%' }"></div>
|
||||
<div class="stacked-neutral" :style="{ width: (data.sentiment_breakdown?.neutral_ratio || 0) + '%' }"></div>
|
||||
<div class="stacked-negative" :style="{ width: (data.sentiment_breakdown?.negative_ratio || 0) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Agents -->
|
||||
<div class="chart-title" style="margin-top: 16px;">Top Agents</div>
|
||||
<div class="agents-table">
|
||||
<div v-for="(agent, idx) in topAgents" :key="idx" class="agent-row">
|
||||
<span class="agent-rank">{{ idx + 1 }}</span>
|
||||
<span class="agent-name">{{ agent.agent_name }}</span>
|
||||
<span class="agent-stat">{{ agent.total_actions }} actions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Sparkline -->
|
||||
<div class="timeline-block" v-if="timeline.length > 0">
|
||||
<div class="chart-title">Activity Timeline (by Round)</div>
|
||||
<div class="sparkline">
|
||||
<div
|
||||
v-for="(round, idx) in timeline"
|
||||
:key="idx"
|
||||
class="spark-bar"
|
||||
:style="{ height: round.heightPct + '%' }"
|
||||
:title="`Round ${round.round_num}: ${round.total} actions`"
|
||||
>
|
||||
<span class="spark-label" v-if="idx === 0 || idx === timeline.length - 1">R{{ round.round_num }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const metricCards = computed(() => {
|
||||
const km = props.data.key_metrics || {}
|
||||
return [
|
||||
{ label: 'Agents', value: km.total_agents ?? '-' },
|
||||
{ label: 'Posts', value: km.total_posts ?? '-' },
|
||||
{ label: 'Engagement', value: km.total_engagement ?? '-' },
|
||||
{ label: 'Avg Activity', value: km.avg_activity ?? '-' },
|
||||
{ label: 'Rounds', value: km.total_rounds ?? '-' },
|
||||
]
|
||||
})
|
||||
|
||||
const actionBars = computed(() => {
|
||||
const dist = props.data.action_distribution?.by_type || {}
|
||||
const byPlatform = props.data.action_distribution?.by_platform || {}
|
||||
const maxVal = Math.max(...Object.values(dist), 1)
|
||||
|
||||
const labels = {
|
||||
CREATE_POST: 'Posts',
|
||||
LIKE_POST: 'Likes',
|
||||
CREATE_COMMENT: 'Comments',
|
||||
REPOST: 'Reposts',
|
||||
FOLLOW: 'Follows',
|
||||
DISLIKE_POST: 'Dislikes',
|
||||
QUOTE_POST: 'Quotes',
|
||||
}
|
||||
|
||||
return Object.entries(dist)
|
||||
.filter(([k]) => labels[k])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6)
|
||||
.map(([type, total]) => {
|
||||
const tw = byPlatform.twitter?.[type] || 0
|
||||
const rd = byPlatform.reddit?.[type] || 0
|
||||
return {
|
||||
label: labels[type] || type,
|
||||
total,
|
||||
twitterPct: (tw / maxVal) * 100,
|
||||
redditPct: (rd / maxVal) * 100,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const topAgents = computed(() => {
|
||||
return (props.data.top_agents || []).slice(0, 5)
|
||||
})
|
||||
|
||||
const timeline = computed(() => {
|
||||
const rounds = props.data.timeline || []
|
||||
if (rounds.length === 0) return []
|
||||
const maxActions = Math.max(...rounds.map(r => r.total), 1)
|
||||
return rounds.map(r => ({
|
||||
...r,
|
||||
heightPct: Math.max((r.total / maxActions) * 100, 4),
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.infographic-dashboard {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #EAEAEA;
|
||||
background: #FAFAFA;
|
||||
}
|
||||
|
||||
.infographic-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.infographic-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #666;
|
||||
background: #EEE;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.infographic-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Metrics Cards */
|
||||
.metrics-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
flex: 1;
|
||||
background: #FFF;
|
||||
border: 1px solid #E5E5E5;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* Charts Row */
|
||||
.charts-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-block {
|
||||
flex: 1;
|
||||
background: #FFF;
|
||||
border: 1px solid #E5E5E5;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Bar Chart */
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
width: 70px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
flex: 1;
|
||||
height: 12px;
|
||||
background: #F0F0F0;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.bar-twitter {
|
||||
background: #1DA1F2;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.bar-reddit {
|
||||
background: #FF4500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.bar-count {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
width: 35px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Sentiment */
|
||||
.sentiment-bars {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sentiment-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sentiment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.sentiment-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sentiment-item.positive .sentiment-dot { background: #4CAF50; }
|
||||
.sentiment-item.neutral .sentiment-dot { background: #9E9E9E; }
|
||||
.sentiment-item.negative .sentiment-dot { background: #F44336; }
|
||||
|
||||
.sentiment-pct {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sentiment-stacked {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #F0F0F0;
|
||||
}
|
||||
|
||||
.stacked-positive { background: #4CAF50; }
|
||||
.stacked-neutral { background: #9E9E9E; }
|
||||
.stacked-negative { background: #F44336; }
|
||||
|
||||
/* Agents Table */
|
||||
.agents-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.agent-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.agent-rank {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #999;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-stat {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Timeline Sparkline */
|
||||
.timeline-block {
|
||||
background: #FFF;
|
||||
border: 1px solid #E5E5E5;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sparkline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.spark-bar {
|
||||
flex: 1;
|
||||
background: #1DA1F2;
|
||||
border-radius: 2px 2px 0 0;
|
||||
min-width: 4px;
|
||||
position: relative;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.spark-label {
|
||||
position: absolute;
|
||||
bottom: -16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 9px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -16,6 +16,12 @@
|
|||
<div class="header-divider"></div>
|
||||
</div>
|
||||
|
||||
<!-- Infographic Dashboard -->
|
||||
<ReportInfographic
|
||||
v-if="infographicData"
|
||||
:data="infographicData"
|
||||
/>
|
||||
|
||||
<!-- Sections List -->
|
||||
<div class="sections-list">
|
||||
<div
|
||||
|
|
@ -393,7 +399,8 @@
|
|||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getAgentLog, getConsoleLog } from '../api/report'
|
||||
import { getAgentLog, getConsoleLog, getReportInfographic } from '../api/report'
|
||||
import ReportInfographic from './ReportInfographic.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
|
@ -426,6 +433,7 @@ const expandedLogs = ref(new Set())
|
|||
const collapsedSections = ref(new Set())
|
||||
const isComplete = ref(false)
|
||||
const startTime = ref(null)
|
||||
const infographicData = ref(null)
|
||||
const leftPanel = ref(null)
|
||||
const rightPanel = ref(null)
|
||||
const logContent = ref(null)
|
||||
|
|
@ -2062,6 +2070,8 @@ const fetchAgentLog = async () => {
|
|||
|
||||
if (log.action === 'report_start') {
|
||||
startTime.value = new Date(log.timestamp)
|
||||
// Fetch infographic data
|
||||
fetchInfographicData()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -2154,6 +2164,18 @@ const fetchConsoleLog = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
const fetchInfographicData = async () => {
|
||||
if (!props.reportId) return
|
||||
try {
|
||||
const res = await getReportInfographic(props.reportId)
|
||||
if (res.success && res.data) {
|
||||
infographicData.value = res.data
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-critical, silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
if (agentLogTimer || consoleLogTimer) return
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue