Files
AIPortPilot/backend/app/routers/reports.py
T
selfrelease 7ec4fb0747 feat(backend): AI 服务层 — 千问流式 LLM + 月报解析 + 健康度计算 + 风险检测 + Copilot
- LLM 客户端:全部 SSE 流式输出,兼容 OpenAI 接口
- AI 月报解析:SSE 流式端点 POST /reports/{id}/parse
- 健康度计算引擎:四维评分(财务/经营/AI商业化/AI成本)
- 风险自动检测引擎:6 条规则自动检测指标越界
- AI Copilot:SSE 流式对话 POST /copilot/chat
- 权限中间件:角色级 + 字段级权限控制
- 测试:21 个新测试(健康度 8 + 风险检测 8 + 权限 5),总计 64 passed
2026-07-18 22:16:40 +08:00

372 lines
14 KiB
Python

"""月报路由:CRUD + 提交 + AI 解析(SSE 流式)。"""
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.models.company import Company
from app.models.health_score import HealthScore
from app.models.report import MonthlyReport
from app.models.risk import RiskEvent
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.schemas.report import (
MonthlyReportCreate,
MonthlyReportListResponse,
MonthlyReportResponse,
MonthlyReportUpdate,
)
from app.services.ai_parser import parse_report
from app.services.health_calculator import calculate_health_score, determine_trend
from app.services.risk_engine import detect_risks
router = APIRouter(prefix="/reports", tags=["reports"])
async def _get_company_or_404(db: AsyncSession, company_id: str, tenant_id: str) -> Company:
"""验证企业属于当前租户。"""
result = await db.execute(
select(Company).where(Company.id == company_id, Company.tenant_id == tenant_id)
)
company = result.scalar_one_or_none()
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
return company
@router.get("", response_model=ApiResponse[MonthlyReportListResponse])
async def list_reports(
company_id: str | None = Query(default=None, description="按企业筛选"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取月报列表。"""
# 获取当前租户的企业 ID 集合
company_query = select(Company.id).where(Company.tenant_id == user.tenant_id)
if company_id:
company_query = company_query.where(Company.id == company_id)
query = select(MonthlyReport).where(
MonthlyReport.company_id.in_(company_query)
)
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar_one()
offset = (page - 1) * page_size
query = query.order_by(MonthlyReport.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
reports = result.scalars().all()
return success(
data=MonthlyReportListResponse(
items=[MonthlyReportResponse.model_validate(r, from_attributes=True) for r in reports],
total=total,
page=page,
page_size=page_size,
)
)
@router.get("/{report_id}", response_model=ApiResponse[MonthlyReportResponse])
async def get_report(
report_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取月报详情。"""
result = await db.execute(
select(MonthlyReport)
.join(Company, MonthlyReport.company_id == Company.id)
.where(MonthlyReport.id == report_id, Company.tenant_id == user.tenant_id)
)
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="月报不存在")
return success(data=MonthlyReportResponse.model_validate(report, from_attributes=True))
@router.post("", response_model=ApiResponse[MonthlyReportResponse], status_code=status.HTTP_201_CREATED)
async def create_report(
req: MonthlyReportCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""创建月报。"""
await _get_company_or_404(db, req.company_id, user.tenant_id)
# 检查同企业同年月是否已有月报
existing = await db.execute(
select(MonthlyReport).where(
MonthlyReport.company_id == req.company_id,
MonthlyReport.period_year == req.period_year,
MonthlyReport.period_month == req.period_month,
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"{req.period_year}{req.period_month}月月报已存在",
)
report = MonthlyReport(
company_id=req.company_id,
period_year=req.period_year,
period_month=req.period_month,
raw_content=req.raw_content,
status="draft",
)
db.add(report)
await db.flush()
return success(
data=MonthlyReportResponse.model_validate(report, from_attributes=True),
message="创建成功",
)
@router.put("/{report_id}", response_model=ApiResponse[MonthlyReportResponse])
async def update_report(
report_id: str,
req: MonthlyReportUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""更新月报。"""
result = await db.execute(
select(MonthlyReport)
.join(Company, MonthlyReport.company_id == Company.id)
.where(MonthlyReport.id == report_id, Company.tenant_id == user.tenant_id)
)
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="月报不存在")
update_data = req.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(report, key, value)
await db.flush()
return success(
data=MonthlyReportResponse.model_validate(report, from_attributes=True),
message="更新成功",
)
@router.post("/{report_id}/submit", response_model=ApiResponse[MonthlyReportResponse])
async def submit_report(
report_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""提交月报(状态从 draft → submitted)。"""
result = await db.execute(
select(MonthlyReport)
.join(Company, MonthlyReport.company_id == Company.id)
.where(MonthlyReport.id == report_id, Company.tenant_id == user.tenant_id)
)
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="月报不存在")
if report.status != "draft":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"当前状态为 {report.status},无法提交",
)
report.status = "submitted"
report.submitted_by = user.id
report.submitted_at = datetime.now(timezone.utc)
await db.flush()
return success(
data=MonthlyReportResponse.model_validate(report, from_attributes=True),
message="提交成功",
)
@router.delete("/{report_id}", response_model=ApiResponse[None])
async def delete_report(
report_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""删除月报。"""
result = await db.execute(
select(MonthlyReport)
.join(Company, MonthlyReport.company_id == Company.id)
.where(MonthlyReport.id == report_id, Company.tenant_id == user.tenant_id)
)
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="月报不存在")
await db.delete(report)
return success(message="删除成功")
@router.post("/{report_id}/parse")
async def parse_report_stream(
report_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""AI 解析月报(SSE 流式输出)。
流式返回解析过程中的 token,最后返回完整结构化结果。
解析完成后自动计算健康度评分并检测风险事件。
"""
result = await db.execute(
select(MonthlyReport)
.join(Company, MonthlyReport.company_id == Company.id)
.where(MonthlyReport.id == report_id, Company.tenant_id == user.tenant_id)
)
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="月报不存在")
async def event_stream():
"""SSE 事件流。"""
try:
# 阶段 1:流式输出 AI 解析
yield f"data: {json.dumps({'type': 'status', 'message': 'AI 解析中...'})}\n\n"
from app.services.llm_client import llm_client
system_prompt = """你是投后管理领域的专业分析师。请分析以下企业月报内容,提取结构化信息。
输出 JSON 格式如下:
{
"structured_data": {
"revenue": {"value": "", "unit": "万元", "yoy_change": "", "note": ""},
"cash_balance": {"value": "", "unit": "万元", "runway_months": 0, "note": ""},
"burn_rate": {"value": "", "unit": "万元/月", "trend": "up/stable/down", "note": ""},
"headcount": {"total": 0, "new_hires": 0, "departures": 0, "note": ""},
"key_metrics": [{"name": "", "value": "", "change": "", "note": ""}]
},
"ai_summary": "一段 100-200 字的月报摘要",
"ai_concerns": {
"items": [
{"category": "financial/operational/org/ai_specific", "severity": "low/medium/high", "description": ""}
],
"highlights": ["本期亮点1", "本期亮点2"]
}
}
严格输出 JSON,不要包含 markdown 代码块标记。"""
user_prompt = f"请分析以下 {report.period_year}{report.period_month}月 月报内容:\n\n<<<USER_INPUT>>>\n{report.raw_content or '无内容'}\n<<<END_USER_INPUT>>>"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
# 流式收集
collected = []
async for token in llm_client.chat_json_stream(messages, temperature=0.3, max_tokens=2000):
collected.append(token)
yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
# 解析完整 JSON
full_text = "".join(collected).strip()
if full_text.startswith("```"):
full_text = full_text.split("\n", 1)[1] if "\n" in full_text else full_text[3:]
if full_text.endswith("```"):
full_text = full_text[:-3]
full_text = full_text.strip()
parsed = json.loads(full_text)
# 阶段 2:保存解析结果
report.structured_data = parsed.get("structured_data", {})
report.ai_summary = parsed.get("ai_summary", "")
report.ai_concerns = parsed.get("ai_concerns", {})
report.status = "ai_parsed"
await db.flush()
yield f"data: {json.dumps({'type': 'parsed', 'data': parsed})}\n\n"
# 阶段 3:计算健康度评分
yield f"data: {json.dumps({'type': 'status', 'message': '计算健康度评分...'})}\n\n"
scores = calculate_health_score(parsed.get("structured_data", {}))
# 查询上期评分判断趋势
prev_result = await db.execute(
select(HealthScore)
.where(HealthScore.company_id == report.company_id)
.order_by(HealthScore.calculated_at.desc())
.limit(1)
)
prev_score = prev_result.scalar_one_or_none()
prev_total = prev_score.total_score if prev_score else None
trend = determine_trend(scores["total_score"], prev_total)
health = HealthScore(
company_id=report.company_id,
total_score=scores["total_score"],
financial_score=scores["financial_score"],
operational_score=scores["operational_score"],
ai_commercial_score=scores["ai_commercial_score"],
ai_cost_score=scores["ai_cost_score"],
trend=trend,
evidence_json={"report_id": report.id, "period": f"{report.period_year}-{report.period_month}"},
)
db.add(health)
await db.flush()
yield f"data: {json.dumps({'type': 'health_score', 'data': scores, 'trend': trend})}\n\n"
# 阶段 4:风险自动检测
yield f"data: {json.dumps({'type': 'status', 'message': '检测风险事件...'})}\n\n"
risks = detect_risks(parsed.get("structured_data", {}), report.company_id)
created_risks = []
for risk_data in risks:
risk = RiskEvent(
company_id=risk_data["company_id"],
type=risk_data["type"],
severity=risk_data["severity"],
title=risk_data["title"],
description=risk_data["description"],
suggested_action=risk_data["suggested_action"],
status="open",
)
db.add(risk)
await db.flush()
created_risks.append({
"id": risk.id,
"title": risk_data["title"],
"severity": risk_data["severity"],
"type": risk_data["type"],
})
yield f"data: {json.dumps({'type': 'risks', 'data': created_risks})}\n\n"
# 完成
yield f"data: {json.dumps({'type': 'done', 'message': '解析完成'})}\n\n"
except json.JSONDecodeError as e:
yield f"data: {json.dumps({'type': 'error', 'message': f'JSON 解析失败: {e}'})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)