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:
selfrelease
2026-07-18 22:16:40 +08:00
parent 94be6189e9
commit 7ec4fb0747
15 changed files with 1244 additions and 52 deletions
+5 -3
View File
@@ -26,9 +26,11 @@ class Settings(BaseSettings):
jwt_access_token_ttl_minutes: int = 120
jwt_refresh_token_ttl_days: int = 7
# AI / Ollama
ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen2.5:7b"
# AI / LLM(千问 DashScope OpenAI 兼容模式)
llm_api_key: str = ""
llm_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
llm_model: str = "qwen-plus"
llm_timeout_seconds: int = 60
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+91
View File
@@ -0,0 +1,91 @@
"""角色级 + 字段级权限中间件。
基于用户角色控制 API 访问权限和数据可见性。
"""
from fastapi import Depends, HTTPException, status
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
# 角色层级
ROLE_HIERARCHY = {
"admin": 100,
"investor": 50,
"founder": 20,
}
def require_role(*allowed_roles: str):
"""角色级权限依赖工厂。
用法:
@router.get("/admin-only", dependencies=[Depends(require_role("admin"))])
"""
async def _check(user: User = Depends(get_current_user)) -> User:
if user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"需要角色: {', '.join(allowed_roles)},当前角色: {user.role}",
)
return user
return _check
def require_min_role(min_role: str):
"""最低角色层级权限依赖工厂。
用法:
@router.get("/investor+", dependencies=[Depends(require_min_role("investor"))])
"""
min_level = ROLE_HIERARCHY.get(min_role, 0)
async def _check(user: User = Depends(get_current_user)) -> User:
user_level = ROLE_HIERARCHY.get(user.role, 0)
if user_level < min_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"需要最低角色: {min_role},当前角色: {user.role}",
)
return user
return _check
# 字段级权限:不同角色可见的字段
FIELD_VISIBILITY = {
"founder": {
"company": ["id", "name", "industry", "stage", "description", "website"],
"report": ["id", "company_id", "period_year", "period_month", "status", "raw_content"],
},
"investor": {
"company": ["*"], # 全部可见
"report": ["*"],
},
"admin": {
"company": ["*"],
"report": ["*"],
},
}
def filter_fields(
resource: str,
data: dict,
user: User,
) -> dict:
"""根据用户角色过滤返回字段。
Args:
resource: 资源名称(company / report 等)
data: 原始数据字典
user: 当前用户
Returns:
过滤后的数据字典
"""
allowed = FIELD_VISIBILITY.get(user.role, {}).get(resource, ["*"])
if "*" in allowed:
return data
return {k: v for k, v in data.items() if k in allowed}
+2
View File
@@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse
from app.routers.auth import router as auth_router
from app.routers.companies import router as companies_router
from app.routers.copilot import router as copilot_router
from app.routers.dashboard import router as dashboard_router
from app.routers.reports import router as reports_router
from app.routers.risks import router as risks_router
@@ -75,3 +76,4 @@ app.include_router(companies_router, prefix="/api/v1")
app.include_router(reports_router, prefix="/api/v1")
app.include_router(dashboard_router, prefix="/api/v1")
app.include_router(risks_router, prefix="/api/v1")
app.include_router(copilot_router, prefix="/api/v1")
+101
View File
@@ -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",
},
)
+166 -1
View File
@@ -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",
},
)
+97
View File
@@ -0,0 +1,97 @@
"""月报 AI 解析服务。
使用千问 LLM 从月报原始文本中提取:
1. 结构化指标数据(营收、现金流、团队等)
2. AI 摘要
3. 关注点列表
"""
import logging
from typing import Any
from app.services.llm_client import llm_client
logger = logging.getLogger(__name__)
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"]
}
}
注意:
- 如果月报内容不足以提取某项指标,对应字段留空或为 0
- severity 只能是 low/medium/high
- category 只能是 financial/operational/org/ai_specific
- 严格输出 JSON,不要包含其他文字"""
USER_PROMPT_TEMPLATE = """请分析以下 {period} 月报内容:
<<<USER_INPUT>>>
{content}
<<<END_USER_INPUT>>>
"""
async def parse_report(
content: str,
period_year: int,
period_month: int,
) -> dict[str, Any]:
"""解析月报内容,返回结构化数据。
Args:
content: 月报原始文本
period_year: 报告年份
period_month: 报告月份
Returns:
包含 structured_data / ai_summary / ai_concerns 的字典
Raises:
RuntimeError: LLM 调用失败
"""
if not content or not content.strip():
return {
"structured_data": {},
"ai_summary": "月报内容为空",
"ai_concerns": {"items": [], "highlights": []},
}
user_prompt = USER_PROMPT_TEMPLATE.format(
period=f"{period_year}{period_month}",
content=content[:4000], # 限制输入长度
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
try:
result = await llm_client.chat_json(messages, temperature=0.3, max_tokens=2000)
logger.info("月报 AI 解析成功: period=%s-%s", period_year, period_month)
return result
except RuntimeError as e:
logger.error("月报 AI 解析失败: %s", e)
# 降级:返回空结构
return {
"structured_data": {},
"ai_summary": f"AI 解析失败: {e}",
"ai_concerns": {"items": [], "highlights": []},
"fallback_used": True,
}
+228
View File
@@ -0,0 +1,228 @@
"""健康度评分计算引擎。
基于月报结构化数据,计算四维评分:
- 财务健康度(financial_score
- 经营健康度(operational_score
- AI 商业化度(ai_commercial_score
- AI 成本效率(ai_cost_score
总分 = 加权平均,输出 0-100 分。
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
# 权重配置
WEIGHTS = {
"financial": 0.35,
"operational": 0.25,
"ai_commercial": 0.25,
"ai_cost": 0.15,
}
def _safe_float(value: Any, default: float = 0.0) -> float:
"""安全转换为 float。"""
if value is None or value == "":
return default
try:
return float(value)
except (ValueError, TypeError):
return default
def _calc_financial_score(data: dict[str, Any]) -> float:
"""计算财务健康度。
指标:
- 现金跑道(runway_months):>12 月=90+6-12=60-90<6=<60
- 营收同比增长(yoy_change):正=加分,负=减分
- 烧钱率趋势(burn_rate trend):down=加分,up=减分
"""
score = 50.0 # 基础分
cash = data.get("cash_balance", {})
runway = _safe_float(cash.get("runway_months"))
if runway > 0:
if runway >= 12:
score += 30
elif runway >= 6:
score += 15
elif runway >= 3:
score -= 10
else:
score -= 30
revenue = data.get("revenue", {})
yoy = revenue.get("yoy_change", "")
if yoy:
yoy_val = _safe_float(str(yoy).replace("%", "").replace("+", ""))
if yoy_val > 0:
score += 15
elif yoy_val < 0:
score -= 15
burn = data.get("burn_rate", {})
trend = burn.get("trend", "")
if trend == "down":
score += 10
elif trend == "up":
score -= 10
return max(0, min(100, score))
def _calc_operational_score(data: dict[str, Any]) -> float:
"""计算经营健康度。
指标:
- 团队规模变化(headcount):净增长=加分
- 关键指标达成情况
"""
score = 60.0
headcount = data.get("headcount", {})
new_hires = _safe_float(headcount.get("new_hires"))
departures = _safe_float(headcount.get("departures"))
net_change = new_hires - departures
if net_change > 0:
score += 15
elif net_change < 0:
score -= 10
if departures > 5:
score -= 10 # 高流失率
key_metrics = data.get("key_metrics", [])
if key_metrics:
positive_count = sum(
1 for m in key_metrics
if _safe_float(str(m.get("change", "")).replace("%", "").replace("+", "")) > 0
)
score += (positive_count / len(key_metrics)) * 20
return max(0, min(100, score))
def _calc_ai_commercial_score(data: dict[str, Any]) -> float:
"""计算 AI 商业化度。
基于 key_metrics 中 AI 相关指标的达成情况。
"""
score = 50.0
key_metrics = data.get("key_metrics", [])
ai_metrics = [
m for m in key_metrics
if "ai" in str(m.get("name", "")).lower()
or "模型" in str(m.get("name", ""))
or "推理" in str(m.get("name", ""))
]
if ai_metrics:
for m in ai_metrics:
change = str(m.get("change", ""))
val = _safe_float(change.replace("%", "").replace("+", ""))
if val > 0:
score += 15
elif val < 0:
score -= 10
else:
# 无 AI 相关指标,给中等偏下分数
score = 40.0
return max(0, min(100, score))
def _calc_ai_cost_score(data: dict[str, Any]) -> float:
"""计算 AI 成本效率。
基于 burn_rate 和 AI 相关支出估算。
"""
score = 55.0
burn = data.get("burn_rate", {})
trend = burn.get("trend", "")
if trend == "down":
score += 20
elif trend == "up":
score -= 15
# 如果有 key_metrics 中的成本相关指标
key_metrics = data.get("key_metrics", [])
cost_metrics = [
m for m in key_metrics
if "成本" in str(m.get("name", "")) or "cost" in str(m.get("name", "")).lower()
]
for m in cost_metrics:
change = str(m.get("change", ""))
val = _safe_float(change.replace("%", "").replace("+", ""))
if val < 0: # 成本下降是好事
score += 10
elif val > 0:
score -= 10
return max(0, min(100, score))
def calculate_health_score(structured_data: dict[str, Any]) -> dict[str, float]:
"""计算四维健康度评分。
Args:
structured_data: 月报 AI 解析后的结构化数据
Returns:
包含 total_score 和四个维度分数的字典
"""
if not structured_data:
return {
"total_score": 0.0,
"financial_score": 0.0,
"operational_score": 0.0,
"ai_commercial_score": 0.0,
"ai_cost_score": 0.0,
}
financial = _calc_financial_score(structured_data)
operational = _calc_operational_score(structured_data)
ai_commercial = _calc_ai_commercial_score(structured_data)
ai_cost = _calc_ai_cost_score(structured_data)
total = (
financial * WEIGHTS["financial"]
+ operational * WEIGHTS["operational"]
+ ai_commercial * WEIGHTS["ai_commercial"]
+ ai_cost * WEIGHTS["ai_cost"]
)
result = {
"total_score": round(total, 1),
"financial_score": round(financial, 1),
"operational_score": round(operational, 1),
"ai_commercial_score": round(ai_commercial, 1),
"ai_cost_score": round(ai_cost, 1),
}
logger.info("健康度评分计算完成: %s", result)
return result
def determine_trend(current_score: float, previous_score: float | None) -> str:
"""判断评分趋势。
Args:
current_score: 当前评分
previous_score: 上期评分(如有)
Returns:
"up" / "down" / "stable"
"""
if previous_score is None:
return "stable"
diff = current_score - previous_score
if diff > 5:
return "up"
elif diff < -5:
return "down"
return "stable"
+179
View File
@@ -0,0 +1,179 @@
"""LLM 客户端 — 千问 DashScope OpenAI 兼容模式。
提供统一的 LLM 调用接口,全部使用流式输出(SSE)。
"""
import json
import logging
from collections.abc import AsyncGenerator
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
class LLMClient:
"""千问 LLM 客户端(OpenAI 兼容接口,流式输出)。"""
def __init__(
self,
api_key: str | None = None,
base_url: str | None = None,
model: str | None = None,
timeout: int | None = None,
):
self.api_key = api_key or settings.llm_api_key
self.base_url = base_url or settings.llm_base_url
self.model = model or settings.llm_model
self.timeout = timeout or settings.llm_timeout_seconds
def _build_headers(self) -> dict[str, str]:
"""构建请求头。"""
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
def _build_payload(
self,
messages: list[dict[str, str]],
temperature: float,
max_tokens: int,
) -> dict[str, Any]:
"""构建请求体。"""
return {
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
}
async def chat_stream(
self,
messages: list[dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 2000,
) -> AsyncGenerator[str, None]:
"""流式 chat completion,逐 token yield。
Args:
messages: OpenAI 格式的消息列表
temperature: 温度参数
max_tokens: 最大 token 数
Yields:
每个 token 的文本片段
Raises:
RuntimeError: API 调用失败
"""
if not self.api_key:
raise RuntimeError("LLM_API_KEY 未配置")
headers = self._build_headers()
payload = self._build_payload(messages, temperature, max_tokens)
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
async with client.stream(
"POST",
f"{self.base_url}/chat/completions",
headers=headers,
json=payload,
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data_str = line[6:]
if data_str.strip() == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
yield content
except json.JSONDecodeError:
continue
except httpx.TimeoutException:
logger.error("LLM 流式请求超时")
raise RuntimeError("LLM 请求超时")
except httpx.HTTPStatusError as e:
logger.error("LLM API 错误: %s", e.response.status_code)
raise RuntimeError(f"LLM API 错误: {e.response.status_code}")
except RuntimeError:
raise
except Exception as e:
logger.error("LLM 流式调用异常: %s", e)
raise RuntimeError(f"LLM 调用失败: {e}")
async def chat(
self,
messages: list[dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 2000,
) -> str:
"""流式调用但收集为完整字符串(兼容非流式调用方)。"""
parts: list[str] = []
async for token in self.chat_stream(messages, temperature, max_tokens):
parts.append(token)
return "".join(parts)
async def chat_json_stream(
self,
messages: list[dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 2000,
) -> AsyncGenerator[str, None]:
"""流式 JSON 输出,逐 token yield 原始文本片段。
调用方自行收集并解析 JSON。
"""
# 确保 system prompt 要求 JSON 输出
messages = list(messages)
if messages and messages[0]["role"] == "system":
if "json" not in messages[0]["content"].lower():
messages[0]["content"] += "\n\n请严格以 JSON 格式输出,不要包含 markdown 代码块标记。"
else:
messages.insert(0, {
"role": "system",
"content": "你是一个专业的投后管理分析助手。请严格以 JSON 格式输出,不要包含 markdown 代码块标记。",
})
async for token in self.chat_stream(messages, temperature, max_tokens):
yield token
async def chat_json(
self,
messages: list[dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 2000,
) -> dict[str, Any]:
"""流式调用但收集为完整 JSON 对象(兼容非流式调用方)。"""
parts: list[str] = []
async for token in self.chat_json_stream(messages, temperature, max_tokens):
parts.append(token)
text = "".join(parts)
# 清理可能的 markdown 代码块
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError as e:
logger.error("LLM JSON 解析失败: %s, 原始文本: %s", e, text[:500])
raise RuntimeError(f"LLM 输出 JSON 解析失败: {e}")
llm_client = LLMClient()
+160
View File
@@ -0,0 +1,160 @@
"""风险自动检测引擎。
基于月报结构化数据,检测指标越界并自动生成风险事件。
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
# 风险检测规则
RULES = [
{
"name": "现金跑道不足",
"type": "financial",
"severity": "critical",
"condition": lambda d: _get_runway(d) < 3 and _get_runway(d) > 0,
"title": "现金跑道不足 3 个月",
"description": "当前现金跑道仅 {runway} 个月,需紧急融资",
"suggested_action": "立即启动融资对话,评估 bridge loan 可能性",
},
{
"name": "现金跑道预警",
"type": "financial",
"severity": "high",
"condition": lambda d: 3 <= _get_runway(d) < 6,
"title": "现金跑道低于 6 个月",
"description": "当前现金跑道 {runway} 个月,需加快融资进度",
"suggested_action": "与创始人沟通融资时间表,准备备选方案",
},
{
"name": "烧钱率上升",
"type": "financial",
"severity": "medium",
"condition": lambda d: _get_burn_trend(d) == "up",
"title": "烧钱率持续上升",
"description": "月度烧钱率呈上升趋势,需关注成本控制",
"suggested_action": "审查主要支出项,制定成本优化计划",
},
{
"name": "营收下滑",
"type": "financial",
"severity": "high",
"condition": lambda d: _get_yoy(revenue=d) < 0,
"title": "营收同比下滑",
"description": "营收同比下降 {yoy}%,需关注业务增长",
"suggested_action": "分析营收下滑原因,调整商业策略",
},
{
"name": "高人员流失",
"type": "org",
"severity": "medium",
"condition": lambda d: _get_departures(d) > 5,
"title": "人员流失率较高",
"description": "本月离职 {departures} 人,需关注团队稳定性",
"suggested_action": "了解离职原因,评估核心岗位风险",
},
{
"name": "团队净缩减",
"type": "org",
"severity": "high",
"condition": lambda d: _get_net_headcount(d) < -3,
"title": "团队规模显著缩减",
"description": "本月团队净减少 {net} 人,需关注组织健康",
"suggested_action": "与创始人沟通团队规划,评估关键岗位覆盖",
},
]
def _get_runway(data: dict[str, Any]) -> float:
"""获取现金跑道月数。"""
cash = data.get("cash_balance", {})
try:
return float(cash.get("runway_months", 0) or 0)
except (ValueError, TypeError):
return 0.0
def _get_burn_trend(data: dict[str, Any]) -> str:
"""获取烧钱率趋势。"""
burn = data.get("burn_rate", {})
return burn.get("trend", "")
def _get_yoy(revenue: dict[str, Any], d: dict[str, Any] = None) -> float:
"""获取营收同比增长率。"""
if d is None:
d = revenue
revenue = d.get("revenue", {})
yoy = revenue.get("yoy_change", "")
try:
return float(str(yoy).replace("%", "").replace("+", "") or 0)
except (ValueError, TypeError):
return 0.0
def _get_departures(data: dict[str, Any]) -> float:
"""获取离职人数。"""
hc = data.get("headcount", {})
try:
return float(hc.get("departures", 0) or 0)
except (ValueError, TypeError):
return 0.0
def _get_net_headcount(data: dict[str, Any]) -> float:
"""获取团队净变化。"""
hc = data.get("headcount", {})
try:
new = float(hc.get("new_hires", 0) or 0)
dep = float(hc.get("departures", 0) or 0)
return new - dep
except (ValueError, TypeError):
return 0.0
def detect_risks(
structured_data: dict[str, Any],
company_id: str,
) -> list[dict[str, Any]]:
"""从月报结构化数据中检测风险事件。
Args:
structured_data: 月报 AI 解析后的结构化数据
company_id: 企业 ID
Returns:
风险事件列表,每项包含 type/severity/title/description/suggested_action
"""
if not structured_data:
return []
risks: list[dict[str, Any]] = []
runway = _get_runway(structured_data)
yoy = _get_yoy(structured_data)
departures = _get_departures(structured_data)
net_hc = _get_net_headcount(structured_data)
for rule in RULES:
try:
if rule["condition"](structured_data):
risk = {
"company_id": company_id,
"type": rule["type"],
"severity": rule["severity"],
"title": rule["title"],
"description": rule["description"].format(
runway=runway,
yoy=abs(yoy),
departures=departures,
net=abs(net_hc),
),
"suggested_action": rule["suggested_action"],
}
risks.append(risk)
logger.info("检测到风险: %s%s", rule["name"], risk["title"])
except Exception as e:
logger.warning("风险检测规则 '%s' 执行异常: %s", rule["name"], e)
return risks
+65
View File
@@ -0,0 +1,65 @@
"""健康度计算引擎测试。"""
from app.services.health_calculator import calculate_health_score, determine_trend
class TestCalculateHealthScore:
"""健康度评分计算。"""
def test_empty_data(self):
"""空数据应返回全 0。"""
result = calculate_health_score({})
assert result["total_score"] == 0.0
assert result["financial_score"] == 0.0
def test_healthy_company(self):
"""健康企业:跑道充足 + 营收增长 + 烧钱下降。"""
data = {
"revenue": {"yoy_change": "30"},
"cash_balance": {"runway_months": 18},
"burn_rate": {"trend": "down"},
"headcount": {"new_hires": 5, "departures": 1},
"key_metrics": [{"name": "ARR", "change": "+25%"}],
}
result = calculate_health_score(data)
assert result["total_score"] > 70
assert result["financial_score"] > 80
def test_unhealthy_company(self):
"""不健康企业:跑道短 + 营收下滑 + 烧钱上升。"""
data = {
"revenue": {"yoy_change": "-20"},
"cash_balance": {"runway_months": 2},
"burn_rate": {"trend": "up"},
"headcount": {"new_hires": 0, "departures": 8},
}
result = calculate_health_score(data)
assert result["total_score"] < 50
assert result["financial_score"] < 30
def test_score_range(self):
"""评分应在 0-100 范围内。"""
data = {
"revenue": {"yoy_change": "1000"},
"cash_balance": {"runway_months": 100},
"burn_rate": {"trend": "down"},
}
result = calculate_health_score(data)
for key, val in result.items():
assert 0 <= val <= 100
class TestDetermineTrend:
"""趋势判断。"""
def test_up(self):
assert determine_trend(80, 60) == "up"
def test_down(self):
assert determine_trend(50, 70) == "down"
def test_stable(self):
assert determine_trend(60, 62) == "stable"
def test_no_previous(self):
assert determine_trend(70, None) == "stable"
+43
View File
@@ -0,0 +1,43 @@
"""权限中间件测试。"""
from types import SimpleNamespace
from app.core.permissions import filter_fields, require_min_role, require_role, ROLE_HIERARCHY
class TestRoleHierarchy:
"""角色层级。"""
def test_admin_highest(self):
assert ROLE_HIERARCHY["admin"] > ROLE_HIERARCHY["investor"]
assert ROLE_HIERARCHY["admin"] > ROLE_HIERARCHY["founder"]
def test_investor_above_founder(self):
assert ROLE_HIERARCHY["investor"] > ROLE_HIERARCHY["founder"]
class TestFilterFields:
"""字段级权限过滤。"""
def test_investor_sees_all(self):
"""investor 可见全部字段。"""
data = {"name": "公司A", "total_funding": "1亿", "description": "测试"}
user = SimpleNamespace(role="investor")
result = filter_fields("company", data, user)
assert result == data
def test_founder_filtered(self):
"""founder 只能看限定字段。"""
data = {"name": "公司A", "total_funding": "1亿", "description": "测试", "id": "123"}
user = SimpleNamespace(role="founder")
result = filter_fields("company", data, user)
assert "name" in result
assert "id" in result
assert "total_funding" not in result
def test_admin_sees_all(self):
"""admin 可见全部字段。"""
data = {"name": "公司A", "total_funding": "1亿"}
user = SimpleNamespace(role="admin")
result = filter_fields("company", data, user)
assert result == data
+61
View File
@@ -0,0 +1,61 @@
"""风险检测引擎测试。"""
from app.services.risk_engine import detect_risks
class TestDetectRisks:
"""风险自动检测。"""
def test_empty_data(self):
"""空数据不应检测到风险。"""
risks = detect_risks({}, "company-1")
assert len(risks) == 0
def test_low_runway_critical(self):
"""跑道 < 3 月应触发 critical 风险。"""
data = {"cash_balance": {"runway_months": 2}}
risks = detect_risks(data, "company-1")
assert any(r["severity"] == "critical" for r in risks)
assert any("3 个月" in r["title"] for r in risks)
def test_low_runway_warning(self):
"""跑道 3-6 月应触发 high 风险。"""
data = {"cash_balance": {"runway_months": 4}}
risks = detect_risks(data, "company-1")
assert any(r["severity"] == "high" and "6 个月" in r["title"] for r in risks)
def test_burn_rate_up(self):
"""烧钱率上升应触发 medium 风险。"""
data = {"burn_rate": {"trend": "up"}}
risks = detect_risks(data, "company-1")
assert any(r["severity"] == "medium" and "烧钱率" in r["title"] for r in risks)
def test_revenue_decline(self):
"""营收下滑应触发 high 风险。"""
data = {"revenue": {"yoy_change": "-15"}}
risks = detect_risks(data, "company-1")
assert any(r["severity"] == "high" and "营收" in r["title"] for r in risks)
def test_high_departures(self):
"""高离职率应触发 medium 风险。"""
data = {"headcount": {"new_hires": 2, "departures": 8}}
risks = detect_risks(data, "company-1")
assert any(r["severity"] == "medium" and "流失" in r["title"] for r in risks)
def test_healthy_company_no_risks(self):
"""健康企业不应检测到风险。"""
data = {
"revenue": {"yoy_change": "20"},
"cash_balance": {"runway_months": 18},
"burn_rate": {"trend": "down"},
"headcount": {"new_hires": 5, "departures": 1},
}
risks = detect_risks(data, "company-1")
assert len(risks) == 0
def test_company_id_in_risks(self):
"""风险事件应包含 company_id。"""
data = {"cash_balance": {"runway_months": 2}}
risks = detect_risks(data, "test-company-id")
for r in risks:
assert r["company_id"] == "test-company-id"