Files
microfish/backend/app/api/report.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
2026-08-31 13:05:21 +07:00

1039 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Report API路由
提供模拟报告生成、获取、对话等接口
"""
import os
import threading
from flask import current_app, request, jsonify, send_file
from . import report_bp
from ..config import Config
from ..services.report_agent import ReportAgent, ReportManager, ReportStatus
from ..models.task import TaskManager, TaskStatus
from ..utils.logger import get_logger
from ..utils.locale import t, get_locale, set_locale
from ..security.auth import authenticate_readonly_request, current_actor
from ..security.resources import enforce_request_scope, scoped_reports, require_scoped_project, require_scoped_simulation
from ..services.memory_tools import LocalMemoryTools
from ..services.product_repository import ProductRepository
from ..services.idempotency import idempotent
from ..utils.api_errors import ApiError, internal_error_payload
logger = get_logger('crowdsight.api.report')
@report_bp.errorhandler(ApiError)
def handle_report_error(error: ApiError):
return jsonify(error.to_payload(t)), error.status_code
def _safe_internal_failure(operation: str, error: Exception):
"""Return a safe response without leaking exception details."""
if isinstance(error, ApiError):
return jsonify(error.to_payload(t)), error.status_code
logger.error("%s failed: error_type=%s", operation, type(error).__name__)
return jsonify(internal_error_payload(t)), 500
def _sync_report_to_durable(
report,
*,
organization_id,
project_id,
simulation_id,
created_by_user_id,
session_factory=None,
):
"""Best-effort dual-write of a legacy report into durable SQL.
Never raises; the filesystem ReportManager remains authoritative if the
durable write is unavailable. Callers inside a worker thread pass the
captured ``session_factory`` explicitly.
"""
try:
if session_factory is None:
session_factory = current_app.extensions.get("crowdsight_session_factory")
if session_factory is None:
return
session = session_factory()
try:
ProductRepository(session).sync_report(
report,
organization_id=organization_id,
project_id=project_id,
simulation_id=simulation_id,
created_by_user_id=created_by_user_id,
commit=True,
)
finally:
session.close()
except Exception:
logger.warning("durable report sync skipped", exc_info=True)
@report_bp.before_request
def _authenticate_report_request():
authenticate_readonly_request()
enforce_request_scope()
def _request_memory_tools(graph_id: str):
"""Build a request-scoped local memory adapter when local mode is active."""
if Config.MEMORY_BACKEND != "local":
return None, None
session_factory = current_app.extensions.get("crowdsight_session_factory")
if session_factory is None:
raise ApiError("memory_backend_unavailable", 503, "api.internalError")
actor = current_actor()
session = session_factory()
try:
tools = LocalMemoryTools(
session,
organization_id=actor.organization_id,
graph_id=graph_id,
)
except Exception:
session.close()
raise
return tools, session
# ============== 报告生成接口 ==============
@report_bp.route('/generate', methods=['POST'])
@idempotent
def generate_report():
"""
生成模拟分析报告(异步任务)
这是一个耗时操作,接口会立即返回task_id,
使用 GET /api/report/generate/status 查询进度
请求(JSON):
{
"simulation_id": "sim_xxxx", // 必填,模拟ID
"force_regenerate": false // 可选,强制重新生成
}
返回:
{
"success": true,
"data": {
"simulation_id": "sim_xxxx",
"task_id": "task_xxxx",
"status": "generating",
"message": "报告生成任务已启动"
}
}
"""
try:
data = request.get_json() or {}
simulation_id = data.get('simulation_id')
if not simulation_id:
return jsonify({
"success": False,
"error": t('api.requireSimulationId')
}), 400
force_regenerate = data.get('force_regenerate', False)
# Fetch both resources through the tenant/owner scope guards.
state = require_scoped_simulation(simulation_id)
# 检查是否已有报告
if not force_regenerate:
existing_report = ReportManager.get_report_by_simulation(simulation_id)
if existing_report and existing_report.status == ReportStatus.COMPLETED:
return jsonify({
"success": True,
"data": {
"simulation_id": simulation_id,
"report_id": existing_report.report_id,
"status": "completed",
"message": t('api.reportAlreadyExists'),
"already_generated": True
}
})
# 获取项目信息
project = require_scoped_project(state.project_id)
graph_id = state.graph_id or project.graph_id
if not graph_id:
return jsonify({
"success": False,
"error": t('api.missingGraphIdEnsure')
}), 400
simulation_requirement = project.simulation_requirement
if not simulation_requirement:
return jsonify({
"success": False,
"error": t('api.missingSimRequirement')
}), 400
# 提前生成 report_id,以便立即返回给前端
import uuid
report_id = f"report_{uuid.uuid4().hex[:12]}"
# Create an ownership-scoped in-memory task record.
task_manager = TaskManager()
actor = current_actor()
organization_id = actor.organization_id
owner_user_id = actor.user_id
project_id = state.project_id
memory_backend = Config.MEMORY_BACKEND
session_factory = current_app.extensions.get("crowdsight_session_factory") if memory_backend == "local" else None
if memory_backend not in {"zep", "local"}:
raise ApiError("invalid_memory_backend", 500, "api.internalError")
if memory_backend == "local" and session_factory is None:
raise ApiError("memory_backend_unavailable", 503, "api.internalError")
task_id = task_manager.create_task(
task_type="report_generate",
metadata={
"simulation_id": simulation_id,
"graph_id": graph_id,
"report_id": report_id,
"organization_id": actor.organization_id,
"owner_user_id": actor.user_id,
}
)
# Capture locale before spawning background thread
current_locale = get_locale()
# 定义后台任务
def run_generate():
set_locale(current_locale)
local_session = None
try:
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message=t('api.initReportAgent')
)
memory_tools = None
if memory_backend == "local":
if session_factory is None:
raise RuntimeError("memory_backend_unavailable")
local_session = session_factory()
memory_tools = LocalMemoryTools(
local_session,
organization_id=organization_id,
graph_id=graph_id,
)
agent = ReportAgent(
graph_id=graph_id,
simulation_id=simulation_id,
simulation_requirement=simulation_requirement,
memory_tools=memory_tools,
)
# 进度回调
def progress_callback(stage, progress, message):
task_manager.update_task(
task_id,
progress=progress,
message=f"[{stage}] {message}"
)
# 生成报告(传入预先生成的 report_id)
report = agent.generate_report(
progress_callback=progress_callback,
report_id=report_id
)
# 保存报告
ReportManager.save_report(report)
# Best-effort dual-write into the durable repository.
_sync_report_to_durable(
report,
organization_id=organization_id,
project_id=project_id,
simulation_id=simulation_id,
created_by_user_id=owner_user_id,
session_factory=session_factory,
)
if report.status == ReportStatus.COMPLETED:
task_manager.complete_task(
task_id,
result={
"report_id": report.report_id,
"simulation_id": simulation_id,
"status": "completed"
}
)
else:
task_manager.fail_task(task_id, t('api.reportGenerateFailed'))
except Exception as exc:
logger.error("Report generation failed: error=%s", type(exc).__name__)
task_manager.fail_task(task_id, t('api.reportGenerateFailed'))
finally:
if local_session is not None:
local_session.close()
# 启动后台线程
thread = threading.Thread(target=run_generate, daemon=True)
thread.start()
return jsonify({
"success": True,
"data": {
"simulation_id": simulation_id,
"report_id": report_id,
"task_id": task_id,
"status": "generating",
"message": t('api.reportGenerateStarted'),
"already_generated": False
}
})
except Exception as e:
return _safe_internal_failure("start report generation", e)
@report_bp.route('/generate/status', methods=['POST'])
def get_generate_status():
"""
查询报告生成任务进度
请求(JSON):
{
"task_id": "task_xxxx", // 可选,generate返回的task_id
"simulation_id": "sim_xxxx" // 可选,模拟ID
}
返回:
{
"success": true,
"data": {
"task_id": "task_xxxx",
"status": "processing|completed|failed",
"progress": 45,
"message": "..."
}
}
"""
try:
data = request.get_json() or {}
task_id = data.get('task_id')
simulation_id = data.get('simulation_id')
# 如果提供了simulation_id,先检查是否已有完成的报告
if simulation_id:
existing_report = ReportManager.get_report_by_simulation(simulation_id)
if existing_report and existing_report.status == ReportStatus.COMPLETED:
return jsonify({
"success": True,
"data": {
"simulation_id": simulation_id,
"report_id": existing_report.report_id,
"status": "completed",
"progress": 100,
"message": t('api.reportGenerated'),
"already_completed": True
}
})
if not task_id:
return jsonify({
"success": False,
"error": t('api.requireTaskOrSimId')
}), 400
task_manager = TaskManager()
task = task_manager.get_task(task_id)
if not task:
return jsonify({
"success": False,
"error": t('api.taskNotFound', id=task_id)
}), 404
return jsonify({
"success": True,
"data": task.to_dict()
})
except Exception as e:
return _safe_internal_failure("get report task status", e)
# ============== 报告获取接口 ==============
@report_bp.route('/<report_id>', methods=['GET'])
def get_report(report_id: str):
"""
获取报告详情
返回:
{
"success": true,
"data": {
"report_id": "report_xxxx",
"simulation_id": "sim_xxxx",
"status": "completed",
"outline": {...},
"markdown_content": "...",
"created_at": "...",
"completed_at": "..."
}
}
"""
try:
report = ReportManager.get_report(report_id)
if not report:
return jsonify({
"success": False,
"error": t('api.reportNotFound', id=report_id)
}), 404
return jsonify({
"success": True,
"data": report.to_dict()
})
except Exception as e:
return _safe_internal_failure("get report", e)
@report_bp.route('/by-simulation/<simulation_id>', methods=['GET'])
def get_report_by_simulation(simulation_id: str):
"""
根据模拟ID获取报告
返回:
{
"success": true,
"data": {
"report_id": "report_xxxx",
...
}
}
"""
try:
report = ReportManager.get_report_by_simulation(simulation_id)
if not report:
return jsonify({
"success": False,
"error": t('api.noReportForSim', id=simulation_id),
"has_report": False
}), 404
return jsonify({
"success": True,
"data": report.to_dict(),
"has_report": True
})
except Exception as e:
return _safe_internal_failure("get report", e)
@report_bp.route('/list', methods=['GET'])
def list_reports():
"""
列出所有报告
Query参数:
simulation_id: 按模拟ID过滤(可选)
limit: 返回数量限制(默认50)
返回:
{
"success": true,
"data": [...],
"count": 10
}
"""
try:
simulation_id = request.args.get('simulation_id')
limit = request.args.get('limit', 50, type=int)
reports = scoped_reports(
simulation_id=simulation_id,
limit=limit,
)
return jsonify({
"success": True,
"data": [r.to_dict() for r in reports],
"count": len(reports)
})
except Exception as e:
return _safe_internal_failure("list reports", e)
@report_bp.route('/<report_id>/download', methods=['GET'])
def download_report(report_id: str):
"""
下载报告(Markdown格式)
返回Markdown文件
"""
try:
report = ReportManager.get_report(report_id)
if not report:
return jsonify({
"success": False,
"error": t('api.reportNotFound', id=report_id)
}), 404
md_path = ReportManager._get_report_markdown_path(report_id)
if not os.path.exists(md_path):
# 如果MD文件不存在,生成一个临时文件
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
f.write(report.markdown_content)
temp_path = f.name
return send_file(
temp_path,
as_attachment=True,
download_name=f"{report_id}.md"
)
return send_file(
md_path,
as_attachment=True,
download_name=f"{report_id}.md"
)
except Exception as e:
return _safe_internal_failure("download report", e)
@report_bp.route('/<report_id>', methods=['DELETE'])
def delete_report(report_id: str):
"""删除报告"""
try:
success = ReportManager.delete_report(report_id)
if not success:
return jsonify({
"success": False,
"error": t('api.reportNotFound', id=report_id)
}), 404
return jsonify({
"success": True,
"message": t('api.reportDeleted', id=report_id)
})
except Exception as e:
return _safe_internal_failure("delete report", e)
# ============== Report Agent对话接口 ==============
@report_bp.route('/chat', methods=['POST'])
@idempotent
def chat_with_report_agent():
"""
与Report Agent对话
Report Agent可以在对话中自主调用检索工具来回答问题
请求(JSON):
{
"simulation_id": "sim_xxxx", // 必填,模拟ID
"message": "请解释一下舆情走向", // 必填,用户消息
"chat_history": [ // 可选,对话历史
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."}
]
}
返回:
{
"success": true,
"data": {
"response": "Agent回复...",
"tool_calls": [调用的工具列表],
"sources": [信息来源]
}
}
"""
try:
data = request.get_json() or {}
simulation_id = data.get('simulation_id')
message = data.get('message')
chat_history = data.get('chat_history', [])
if not simulation_id:
return jsonify({
"success": False,
"error": t('api.requireSimulationId')
}), 400
if not message:
return jsonify({
"success": False,
"error": t('api.requireMessage')
}), 400
# Resolve both resources through tenant/owner scope guards.
state = require_scoped_simulation(simulation_id)
project = require_scoped_project(state.project_id)
graph_id = state.graph_id or project.graph_id
if not graph_id:
return jsonify({
"success": False,
"error": t('api.missingGraphId')
}), 400
simulation_requirement = project.simulation_requirement or ""
memory_tools, local_session = _request_memory_tools(graph_id)
try:
agent = ReportAgent(
graph_id=graph_id,
simulation_id=simulation_id,
simulation_requirement=simulation_requirement,
memory_tools=memory_tools,
)
result = agent.chat(message=message, chat_history=chat_history)
return jsonify({
"success": True,
"data": result
})
finally:
if local_session is not None:
local_session.close()
except Exception as e:
return _safe_internal_failure("chat with report agent", e)
# ============== 报告进度与分章节接口 ==============
@report_bp.route('/<report_id>/progress', methods=['GET'])
def get_report_progress(report_id: str):
"""
获取报告生成进度(实时)
返回:
{
"success": true,
"data": {
"status": "generating",
"progress": 45,
"message": "正在生成章节: 关键发现",
"current_section": "关键发现",
"completed_sections": ["执行摘要", "模拟背景"],
"updated_at": "2025-12-09T..."
}
}
"""
try:
progress = ReportManager.get_progress(report_id)
if not progress:
return jsonify({
"success": False,
"error": t('api.reportProgressNotAvail', id=report_id)
}), 404
return jsonify({
"success": True,
"data": progress
})
except Exception as e:
return _safe_internal_failure("get report progress", e)
@report_bp.route('/<report_id>/sections', methods=['GET'])
def get_report_sections(report_id: str):
"""
获取已生成的章节列表(分章节输出)
前端可以轮询此接口获取已生成的章节内容,无需等待整个报告完成
返回:
{
"success": true,
"data": {
"report_id": "report_xxxx",
"sections": [
{
"filename": "section_01.md",
"section_index": 1,
"content": "## 执行摘要\\n\\n..."
},
...
],
"total_sections": 3,
"is_complete": false
}
}
"""
try:
sections = ReportManager.get_generated_sections(report_id)
# 获取报告状态
report = ReportManager.get_report(report_id)
is_complete = report is not None and report.status == ReportStatus.COMPLETED
return jsonify({
"success": True,
"data": {
"report_id": report_id,
"sections": sections,
"total_sections": len(sections),
"is_complete": is_complete
}
})
except Exception as e:
return _safe_internal_failure("get report sections", e)
@report_bp.route('/<report_id>/section/<int:section_index>', methods=['GET'])
def get_single_section(report_id: str, section_index: int):
"""
获取单个章节内容
返回:
{
"success": true,
"data": {
"filename": "section_01.md",
"content": "## 执行摘要\\n\\n..."
}
}
"""
try:
section_path = ReportManager._get_section_path(report_id, section_index)
if not os.path.exists(section_path):
return jsonify({
"success": False,
"error": t('api.sectionNotFound', index=f"{section_index:02d}")
}), 404
with open(section_path, 'r', encoding='utf-8') as f:
content = f.read()
return jsonify({
"success": True,
"data": {
"filename": f"section_{section_index:02d}.md",
"section_index": section_index,
"content": content
}
})
except Exception as e:
return _safe_internal_failure("get report section", e)
# ============== 报告状态检查接口 ==============
@report_bp.route('/check/<simulation_id>', methods=['GET'])
def check_report_status(simulation_id: str):
"""
检查模拟是否有报告,以及报告状态
用于前端判断是否解锁Interview功能
返回:
{
"success": true,
"data": {
"simulation_id": "sim_xxxx",
"has_report": true,
"report_status": "completed",
"report_id": "report_xxxx",
"interview_unlocked": true
}
}
"""
try:
report = ReportManager.get_report_by_simulation(simulation_id)
has_report = report is not None
report_status = report.status.value if report else None
report_id = report.report_id if report else None
# 只有报告完成后才解锁interview
interview_unlocked = has_report and report.status == ReportStatus.COMPLETED
return jsonify({
"success": True,
"data": {
"simulation_id": simulation_id,
"has_report": has_report,
"report_status": report_status,
"report_id": report_id,
"interview_unlocked": interview_unlocked
}
})
except Exception as e:
return _safe_internal_failure("check report status", e)
# ============== Agent 日志接口 ==============
@report_bp.route('/<report_id>/agent-log', methods=['GET'])
def get_agent_log(report_id: str):
"""
获取 Report Agent 的详细执行日志
实时获取报告生成过程中的每一步动作,包括:
- 报告开始、规划开始/完成
- 每个章节的开始、工具调用、LLM响应、完成
- 报告完成或失败
Query参数:
from_line: 从第几行开始读取(可选,默认0,用于增量获取)
返回:
{
"success": true,
"data": {
"logs": [
{
"timestamp": "2025-12-13T...",
"elapsed_seconds": 12.5,
"report_id": "report_xxxx",
"action": "tool_call",
"stage": "generating",
"section_title": "执行摘要",
"section_index": 1,
"details": {
"tool_name": "insight_forge",
"parameters": {...},
...
}
},
...
],
"total_lines": 25,
"from_line": 0,
"has_more": false
}
}
"""
try:
from_line = request.args.get('from_line', 0, type=int)
log_data = ReportManager.get_agent_log(report_id, from_line=from_line)
return jsonify({
"success": True,
"data": log_data
})
except Exception as e:
return _safe_internal_failure("get agent log", e)
@report_bp.route('/<report_id>/agent-log/stream', methods=['GET'])
def stream_agent_log(report_id: str):
"""
获取完整的 Agent 日志(一次性获取全部)
返回:
{
"success": true,
"data": {
"logs": [...],
"count": 25
}
}
"""
try:
logs = ReportManager.get_agent_log_stream(report_id)
return jsonify({
"success": True,
"data": {
"logs": logs,
"count": len(logs)
}
})
except Exception as e:
return _safe_internal_failure("get agent log", e)
# ============== 控制台日志接口 ==============
@report_bp.route('/<report_id>/console-log', methods=['GET'])
def get_console_log(report_id: str):
"""
获取 Report Agent 的控制台输出日志
实时获取报告生成过程中的控制台输出(INFO、WARNING等),
这与 agent-log 接口返回的结构化 JSON 日志不同,
是纯文本格式的控制台风格日志。
Query参数:
from_line: 从第几行开始读取(可选,默认0,用于增量获取)
返回:
{
"success": true,
"data": {
"logs": [
"[19:46:14] INFO: 搜索完成: 找到 15 条相关事实",
"[19:46:14] INFO: 图谱搜索: graph_id=xxx, query=...",
...
],
"total_lines": 100,
"from_line": 0,
"has_more": false
}
}
"""
try:
from_line = request.args.get('from_line', 0, type=int)
log_data = ReportManager.get_console_log(report_id, from_line=from_line)
return jsonify({
"success": True,
"data": log_data
})
except Exception as e:
return _safe_internal_failure("get console log", e)
@report_bp.route('/<report_id>/console-log/stream', methods=['GET'])
def stream_console_log(report_id: str):
"""
获取完整的控制台日志(一次性获取全部)
返回:
{
"success": true,
"data": {
"logs": [...],
"count": 100
}
}
"""
try:
logs = ReportManager.get_console_log_stream(report_id)
return jsonify({
"success": True,
"data": {
"logs": logs,
"count": len(logs)
}
})
except Exception as e:
return _safe_internal_failure("get console log", e)
# ============== 工具调用接口(供调试使用)==============
@report_bp.route('/tools/search', methods=['POST'])
def search_graph_tool():
"""
图谱搜索工具接口(供调试使用)
请求(JSON):
{
"graph_id": "crowdsight_xxxx",
"query": "搜索查询",
"limit": 10
}
"""
try:
data = request.get_json() or {}
graph_id = data.get('graph_id')
query = data.get('query')
limit = data.get('limit', 10)
if not graph_id or not query:
return jsonify({
"success": False,
"error": t('api.requireGraphIdAndQuery')
}), 400
tools, local_session = _request_memory_tools(graph_id)
if tools is None:
from ..services.zep_tools import ZepToolsService
tools = ZepToolsService()
try:
result = tools.search_graph(
graph_id=graph_id,
query=query,
limit=limit
)
finally:
if local_session is not None:
local_session.close()
return jsonify({
"success": True,
"data": result.to_dict()
})
except Exception as e:
return _safe_internal_failure("search graph tool", e)
@report_bp.route('/tools/statistics', methods=['POST'])
def get_graph_statistics_tool():
"""
图谱统计工具接口(供调试使用)
请求(JSON):
{
"graph_id": "crowdsight_xxxx"
}
"""
try:
data = request.get_json() or {}
graph_id = data.get('graph_id')
if not graph_id:
return jsonify({
"success": False,
"error": t('api.requireGraphId')
}), 400
tools, local_session = _request_memory_tools(graph_id)
if tools is None:
from ..services.zep_tools import ZepToolsService
tools = ZepToolsService()
try:
result = tools.get_graph_statistics(graph_id)
finally:
if local_session is not None:
local_session.close()
return jsonify({
"success": True,
"data": result
})
except Exception as e:
return _safe_internal_failure("get graph statistics tool", e)