feat(backend): 6-axis dynamic evaluation system with weight engine and template management

This commit is contained in:
selfrelease
2026-07-19 20:59:31 +08:00
parent f4ddcab2ca
commit de53a252e4
11 changed files with 2145 additions and 1 deletions
+299
View File
@@ -0,0 +1,299 @@
"""评价指标体系 API 集成测试。"""
from fastapi.testclient import TestClient
class TestWeightComputeAPI:
"""权重计算 API 测试。"""
def test_compute_weights_success(self, client: TestClient, auth_headers: dict):
"""POST /evaluation/weights/compute 应返回权重计算结果。"""
resp = client.post(
"/api/v1/evaluation/weights/compute",
json={
"fund_type": "early_vc",
"fund_lifecycle": "investment",
"company_stage": "a",
"industry": "ai",
"strategy": "growth",
"investor_type": "investor",
},
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()["data"]
assert "weights" in data
assert "enabled_dimensions" in data
assert "disabled_dimensions" in data
assert "custom_metrics" in data
# 权重总和应接近 100
total = sum(data["weights"].values())
assert abs(total - 100.0) < 1.0
def test_compute_weights_no_auth(self, client: TestClient):
"""未认证应返回 401。"""
resp = client.post(
"/api/v1/evaluation/weights/compute",
json={
"fund_type": "early_vc",
"company_stage": "a",
"industry": "ai",
},
)
assert resp.status_code == 401
def test_compute_weights_hardware_disables_ai(self, client: TestClient, auth_headers: dict):
"""硬科技赛道应禁用 AI 维度。"""
resp = client.post(
"/api/v1/evaluation/weights/compute",
json={
"fund_type": "early_vc",
"fund_lifecycle": "investment",
"company_stage": "a",
"industry": "hardware",
},
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()["data"]
assert "ai_commercial" in data["disabled_dimensions"]
assert "ai_cost" in data["disabled_dimensions"]
class TestTemplateAPI:
"""评价模板 API 测试。"""
def test_list_templates_empty(self, client: TestClient, auth_headers: dict):
"""无模板时应返回空列表。"""
resp = client.get("/api/v1/evaluation/templates", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json()["data"], list)
def test_list_templates_no_auth(self, client: TestClient):
"""未认证应返回 401。"""
resp = client.get("/api/v1/evaluation/templates")
assert resp.status_code == 401
def test_create_template_auto_weights(self, client: TestClient, auth_headers: dict):
"""创建模板时不传权重应自动计算。"""
resp = client.post(
"/api/v1/evaluation/templates",
json={
"name": "测试模板-早期VC-AI",
"fund_type": "early_vc",
"fund_lifecycle": "investment",
"company_stage": "a",
"industry": "ai",
"strategy": "growth",
},
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()["data"]
assert "id" in data
assert "weights" in data
def test_create_template_with_custom_weights(self, client: TestClient, auth_headers: dict):
"""创建模板时传自定义权重应使用自定义权重。"""
resp = client.post(
"/api/v1/evaluation/templates",
json={
"name": "自定义权重模板",
"fund_type": "early_vc",
"fund_lifecycle": "growth",
"company_stage": "b",
"industry": "saas",
"strategy": "value",
"weights_json": {"financial": 40, "product_tech": 30, "market_compete": 30},
},
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["weights"]["financial"] == 40
def test_get_template_by_id(self, client: TestClient, auth_headers: dict):
"""根据 ID 获取模板详情。"""
# 先创建
create_resp = client.post(
"/api/v1/evaluation/templates",
json={
"name": "查询测试模板",
"fund_type": "pe",
"fund_lifecycle": "growth",
"company_stage": "c",
"industry": "fintech",
},
headers=auth_headers,
)
template_id = create_resp.json()["data"]["id"]
# 再查询
resp = client.get(f"/api/v1/evaluation/templates/{template_id}", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["name"] == "查询测试模板"
assert data["fund_type"] == "pe"
def test_get_template_not_found(self, client: TestClient, auth_headers: dict):
"""查询不存在的模板应返回 404。"""
resp = client.get(
"/api/v1/evaluation/templates/nonexistent-id",
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json()["code"] == 404
def test_list_templates_with_filter(self, client: TestClient, auth_headers: dict):
"""按基金类型筛选模板。"""
# 创建两个不同类型模板
client.post(
"/api/v1/evaluation/templates",
json={
"name": "筛选-早期VC",
"fund_type": "early_vc",
"company_stage": "a",
"industry": "ai",
},
headers=auth_headers,
)
client.post(
"/api/v1/evaluation/templates",
json={
"name": "筛选-PE",
"fund_type": "pe",
"company_stage": "b",
"industry": "saas",
},
headers=auth_headers,
)
resp = client.get(
"/api/v1/evaluation/templates?fund_type=pe",
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()["data"]
for tmpl in data:
assert tmpl["fund_type"] == "pe"
class TestScoreCalculateAPI:
"""评分计算 API 测试。"""
def test_calculate_score_without_template(self, client: TestClient, auth_headers: dict, company_id: str):
"""无模板时计算评分应使用默认计算。"""
resp = client.post(
"/api/v1/evaluation/score",
json={
"company_id": company_id,
"structured_data": {
"revenue": {"yoy_change": "30"},
"cash_balance": {"runway_months": 18},
"burn_rate": {"trend": "down"},
},
},
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()["data"]
assert "total_score" in data
assert "dimension_scores" in data
def test_calculate_score_no_auth(self, client: TestClient):
"""未认证应返回 401。"""
resp = client.post(
"/api/v1/evaluation/score",
json={"company_id": "test", "structured_data": {}},
)
assert resp.status_code == 401
def test_calculate_score_with_template(self, client: TestClient, auth_headers: dict, company_id: str):
"""使用模板计算评分。"""
# 先创建模板
tmpl_resp = client.post(
"/api/v1/evaluation/templates",
json={
"name": "评分测试模板",
"fund_type": "early_vc",
"company_stage": "a",
"industry": "ai",
},
headers=auth_headers,
)
template_id = tmpl_resp.json()["data"]["id"]
# 使用模板计算评分
resp = client.post(
"/api/v1/evaluation/score",
json={
"company_id": company_id,
"template_id": template_id,
"structured_data": {
"revenue": {"yoy_change": "25"},
"cash_balance": {"runway_months": 15},
"burn_rate": {"trend": "down"},
"headcount": {"new_hires": 3, "departures": 1},
},
},
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()["data"]
assert "score_id" in data
assert "total_score" in data
assert data["template"] is not None
assert data["template"]["id"] == template_id
class TestScoreHistoryAPI:
"""评分历史 API 测试。"""
def test_list_scores_empty(self, client: TestClient, auth_headers: dict):
"""无评分记录时应返回空列表。"""
resp = client.get("/api/v1/evaluation/scores", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json()["data"], list)
def test_list_scores_no_auth(self, client: TestClient):
"""未认证应返回 401。"""
resp = client.get("/api/v1/evaluation/scores")
assert resp.status_code == 401
class TestFundAPI:
"""基金管理 API 测试。"""
def test_list_funds_empty(self, client: TestClient, auth_headers: dict):
"""无基金时应返回空列表。"""
resp = client.get("/api/v1/evaluation/funds", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json()["data"], list)
def test_create_fund(self, client: TestClient, auth_headers: dict):
"""创建基金。"""
resp = client.post(
"/api/v1/evaluation/funds",
json={
"name": "测试基金一期",
"fund_type": "early_vc",
"strategy": "growth",
"established_date": "2023-01-01",
"total_lifespan_months": 84,
"investment_period_months": 48,
"primary_market": "china_mainland",
},
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()["data"]
assert "id" in data
assert data["current_lifecycle"] == "investment"
def test_create_fund_no_auth(self, client: TestClient):
"""未认证应返回 401。"""
resp = client.post(
"/api/v1/evaluation/funds",
json={"name": "test", "fund_type": "early_vc"},
)
assert resp.status_code == 401
+226
View File
@@ -0,0 +1,226 @@
"""评价权重计算引擎测试。"""
import pytest
from app.services.evaluation_engine import (
BASE_WEIGHTS,
DIMENSION_KEY_MAP,
calculate_weighted_score,
compute_weights,
get_dimension_score_key,
)
class TestComputeWeights:
"""权重计算引擎测试。"""
def test_basic_computation(self):
"""基本权重计算应返回有效结果。"""
result = compute_weights(
fund_type="early_vc",
fund_lifecycle="investment",
company_stage="a",
industry="ai",
strategy="growth",
)
assert "weights" in result
assert "enabled_dimensions" in result
assert "disabled_dimensions" in result
assert "custom_metrics" in result
def test_weights_sum_to_100(self):
"""归一化后权重总和应等于 100。"""
result = compute_weights(
fund_type="early_vc",
fund_lifecycle="growth",
company_stage="b",
industry="saas",
strategy="value",
)
total = sum(result["weights"].values())
assert abs(total - 100.0) < 0.5, f"权重总和应为 100,实际为 {total}"
def test_disabled_dimensions_have_zero_weight(self):
"""禁用的维度不应出现在权重中。"""
result = compute_weights(
fund_type="pe",
fund_lifecycle="investment",
company_stage="c",
industry="hardware",
strategy="value",
)
for dim in result["disabled_dimensions"]:
assert dim not in result["weights"] or result["weights"][dim] == 0
def test_ai_industry_enables_all_dimensions(self):
"""AI 赛道应启用全部 14 维度。"""
result = compute_weights(
fund_type="early_vc",
fund_lifecycle="investment",
company_stage="a",
industry="ai",
)
assert len(result["enabled_dimensions"]) == 14
assert len(result["disabled_dimensions"]) == 0
def test_hardware_disables_ai_dimensions(self):
"""硬科技赛道应禁用 AI 相关维度。"""
result = compute_weights(
fund_type="early_vc",
fund_lifecycle="investment",
company_stage="a",
industry="hardware",
)
assert "ai_commercial" in result["disabled_dimensions"]
assert "ai_cost" in result["disabled_dimensions"]
assert "ai_model_product" in result["disabled_dimensions"]
def test_biotech_disables_market_and_customer(self):
"""生物医药赛道应禁用市场竞争和客户成功。"""
result = compute_weights(
fund_type="angel",
fund_lifecycle="investment",
company_stage="seed",
industry="biotech",
)
assert "market_compete" in result["disabled_dimensions"]
assert "customer_success" in result["disabled_dimensions"]
def test_seed_stage_emphasizes_product_and_team(self):
"""种子期应提高产品技术和组织人才权重。"""
result = compute_weights(
fund_type="angel",
fund_lifecycle="investment",
company_stage="seed",
industry="ai",
)
weights = result["weights"]
# 产品技术权重应高于财务
assert weights.get("product_tech", 0) > weights.get("financial", 0)
# 组织人才权重应高于治理
assert weights.get("org_talent", 0) > weights.get("governance", 0)
def test_pre_ipo_stage_emphasizes_financial_and_governance(self):
"""Pre-IPO 阶段应提高财务和治理权重。"""
result = compute_weights(
fund_type="growth_vc",
fund_lifecycle="exit_preparation",
company_stage="pre_ipo",
industry="ai",
)
weights = result["weights"]
# 财务权重应高于产品技术
assert weights.get("financial", 0) > weights.get("product_tech", 0)
# Pre-IPO 的治理权重应高于种子期
seed_result = compute_weights(
fund_type="growth_vc",
fund_lifecycle="exit_preparation",
company_stage="seed",
industry="ai",
)
assert weights.get("governance", 0) > seed_result["weights"].get("governance", 0)
def test_pe_fund_type_emphasizes_financial(self):
"""PE 基金应大幅提高财务权重。"""
result = compute_weights(
fund_type="pe",
fund_lifecycle="growth",
company_stage="b",
industry="saas",
)
weights = result["weights"]
# PE 的财务权重应高于早期 VC
vc_result = compute_weights(
fund_type="early_vc",
fund_lifecycle="investment",
company_stage="b",
industry="saas",
)
assert weights.get("financial", 0) > vc_result["weights"].get("financial", 0)
def test_custom_metrics_present(self):
"""各赛道应有专属指标。"""
for industry in ["ai", "saas", "hardware", "biotech", "consumer", "fintech"]:
result = compute_weights(
fund_type="early_vc",
fund_lifecycle="investment",
company_stage="a",
industry=industry,
)
assert len(result["custom_metrics"]) >= 3, f"{industry} 赛道专属指标不足"
def test_strategy_adjustment_effect(self):
"""投资策略微调应影响权重。"""
base = compute_weights(
fund_type="early_vc",
fund_lifecycle="growth",
company_stage="b",
industry="ai",
strategy="growth",
)
value = compute_weights(
fund_type="early_vc",
fund_lifecycle="growth",
company_stage="b",
industry="ai",
strategy="value",
)
# 成长型策略市场权重应高于价值型
assert base["weights"].get("market_compete", 0) > value["weights"].get("market_compete", 0)
# 价值型策略财务权重应高于成长型
assert value["weights"].get("financial", 0) > base["weights"].get("financial", 0)
def test_all_weights_non_negative(self):
"""所有权重应为非负数。"""
for fund_type in ["angel", "early_vc", "growth_vc", "pe", "cvc", "distress", "esg"]:
for lifecycle in ["investment", "growth", "exit_preparation", "liquidation"]:
for stage in ["seed", "a", "b", "c", "pre_ipo"]:
for industry in ["ai", "saas", "hardware", "biotech", "consumer", "fintech"]:
result = compute_weights(
fund_type=fund_type,
fund_lifecycle=lifecycle,
company_stage=stage,
industry=industry,
)
for dim, weight in result["weights"].items():
assert weight >= 0, f"{fund_type}/{lifecycle}/{stage}/{industry}{dim} 权重为负: {weight}"
class TestCalculateWeightedScore:
"""加权评分计算测试。"""
def test_basic_weighted_score(self):
"""基本加权评分计算。"""
scores = {"financial": 80, "operational": 70, "product_tech": 90}
weights = {"financial": 30, "operational": 30, "product_tech": 40}
result = calculate_weighted_score(scores, weights)
expected = (80 * 30 + 70 * 30 + 90 * 40) / 100
assert abs(result - expected) < 0.1
def test_missing_dimension_ignored(self):
"""缺失维度的评分应被忽略。"""
scores = {"financial": 80}
weights = {"financial": 50, "operational": 50}
result = calculate_weighted_score(scores, weights)
assert abs(result - 80.0) < 0.1
def test_empty_scores(self):
"""空评分应返回 0。"""
result = calculate_weighted_score({}, {"financial": 100})
assert result == 0.0
class TestDimensionKeyMap:
"""维度 key 映射测试。"""
def test_key_mapping(self):
"""权重 key 应正确映射到评分字段 key。"""
assert get_dimension_score_key("financial") == "financial_score"
assert get_dimension_score_key("ai_commercial") == "ai_commercial_score"
assert get_dimension_score_key("customer_success") == "customer_success_score"
def test_all_dimensions_mapped(self):
"""所有 14 维度都应有映射。"""
assert len(DIMENSION_KEY_MAP) == 14
for key in BASE_WEIGHTS:
assert key in DIMENSION_KEY_MAP, f"维度 {key} 缺少映射"