7ec4fb0747
- LLM 客户端:全部 SSE 流式输出,兼容 OpenAI 接口
- AI 月报解析:SSE 流式端点 POST /reports/{id}/parse
- 健康度计算引擎:四维评分(财务/经营/AI商业化/AI成本)
- 风险自动检测引擎:6 条规则自动检测指标越界
- AI Copilot:SSE 流式对话 POST /copilot/chat
- 权限中间件:角色级 + 字段级权限控制
- 测试:21 个新测试(健康度 8 + 风险检测 8 + 权限 5),总计 64 passed
372 lines
14 KiB
Python
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",
|
|
},
|
|
)
|