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
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""AI Copilot 路由 — SSE 流式对话。
|
||||
|
||||
为投资人和创始人提供 AI 副驾驶对话能力。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.llm_client import llm_client
|
||||
|
||||
router = APIRouter(prefix="/copilot", tags=["copilot"])
|
||||
|
||||
|
||||
class CopilotMessage(BaseModel):
|
||||
"""Copilot 对话请求。"""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000)
|
||||
context: dict | None = Field(default=None, description="可选上下文(企业ID/月报ID等)")
|
||||
|
||||
|
||||
SYSTEM_PROMPT_INVESTOR = """你是 AIPortPilot 投后管理系统的 AI 副驾驶,服务于投资人用户。
|
||||
|
||||
你的职责:
|
||||
1. 分析被投企业的经营状况和财务健康度
|
||||
2. 识别潜在风险并提供预警建议
|
||||
3. 协助撰写投后管理报告和建议
|
||||
4. 解答关于企业治理、融资策略的问题
|
||||
|
||||
回答要求:
|
||||
- 专业、简洁、有数据支撑
|
||||
- 如需引用数据,明确标注来源
|
||||
- 对于不确定的信息,坦诚说明
|
||||
- 使用中文回答"""
|
||||
|
||||
|
||||
SYSTEM_PROMPT_FOUNDER = """你是 AIPortPilot 投后管理系统的 AI 副驾驶,服务于创始人用户。
|
||||
|
||||
你的职责:
|
||||
1. 协助撰写和优化月报内容
|
||||
2. 分析企业经营数据,提供改进建议
|
||||
3. 解答融资、团队管理、业务增长等问题
|
||||
4. 提供行业趋势和竞品分析参考
|
||||
|
||||
回答要求:
|
||||
- 实用、可操作、接地气
|
||||
- 关注创始人的实际痛点
|
||||
- 使用中文回答"""
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def copilot_chat(
|
||||
req: CopilotMessage,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""AI Copilot 对话(SSE 流式输出)。
|
||||
|
||||
根据用户角色使用不同的 system prompt。
|
||||
"""
|
||||
system_prompt = (
|
||||
SYSTEM_PROMPT_FOUNDER if user.role == "founder" else SYSTEM_PROMPT_INVESTOR
|
||||
)
|
||||
|
||||
# 构建上下文
|
||||
context_str = ""
|
||||
if req.context:
|
||||
context_parts = []
|
||||
for key, value in req.context.items():
|
||||
context_parts.append(f"{key}: {value}")
|
||||
context_str = f"\n\n当前上下文:\n" + "\n".join(context_parts)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt + context_str},
|
||||
{"role": "user", "content": f"<<<USER_INPUT>>>\n{req.message}\n<<<END_USER_INPUT>>>"},
|
||||
]
|
||||
|
||||
async def event_stream():
|
||||
"""SSE 流式输出。"""
|
||||
try:
|
||||
async for token in llm_client.chat_stream(messages, temperature=0.7, max_tokens=2000):
|
||||
yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'done'})}\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",
|
||||
},
|
||||
)
|
||||
@@ -1,15 +1,19 @@
|
||||
"""月报路由:CRUD + 提交 + AI 解析占位。"""
|
||||
"""月报路由: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 (
|
||||
@@ -18,6 +22,9 @@ from app.schemas.report import (
|
||||
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"])
|
||||
|
||||
@@ -204,3 +211,161 @@ async def delete_report(
|
||||
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user