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:
@@ -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"}
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user