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:
liyizhouAI 2026-04-17 16:58:53 +08:00
parent 9efdced7f2
commit cd51ebe282
8 changed files with 2098 additions and 2099 deletions

View File

@ -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

View File

@ -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

View File

@ -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_AliceTwitterRound 2
> "价格有点贵但内容对得起这个价格。" Professional_BobRedditRound 3
但也有Agent表达了不同意见
> "课程节奏太快,基础薄弱的人可能跟不上。" Learner_CarolTwitterRound 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请简洁回答问题,引用具体数字。"

View File

@ -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)

View File

@ -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`);
};

View File

@ -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>

View File

@ -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