From 7ec4fb07476b8575f21ad7b01a5dae17f87bee92 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 18 Jul 2026 22:16:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20AI=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=B1=82=20=E2=80=94=20=E5=8D=83=E9=97=AE=E6=B5=81=E5=BC=8F=20?= =?UTF-8?q?LLM=20+=20=E6=9C=88=E6=8A=A5=E8=A7=A3=E6=9E=90=20+=20=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E5=BA=A6=E8=AE=A1=E7=AE=97=20+=20=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=20+=20Copilot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LLM 客户端:全部 SSE 流式输出,兼容 OpenAI 接口 - AI 月报解析:SSE 流式端点 POST /reports/{id}/parse - 健康度计算引擎:四维评分(财务/经营/AI商业化/AI成本) - 风险自动检测引擎:6 条规则自动检测指标越界 - AI Copilot:SSE 流式对话 POST /copilot/chat - 权限中间件:角色级 + 字段级权限控制 - 测试:21 个新测试(健康度 8 + 风险检测 8 + 权限 5),总计 64 passed --- .env.example | 8 +- backend/app/core/config.py | 8 +- backend/app/core/permissions.py | 91 +++++++++ backend/app/main.py | 2 + backend/app/routers/copilot.py | 101 ++++++++++ backend/app/routers/reports.py | 167 +++++++++++++++- backend/app/services/ai_parser.py | 97 +++++++++ backend/app/services/health_calculator.py | 228 ++++++++++++++++++++++ backend/app/services/llm_client.py | 179 +++++++++++++++++ backend/app/services/risk_engine.py | 160 +++++++++++++++ backend/tests/test_health_calculator.py | 65 ++++++ backend/tests/test_permissions.py | 43 ++++ backend/tests/test_risk_engine.py | 61 ++++++ docs/2-task.md | 68 +++---- progress.txt | 18 +- 15 files changed, 1244 insertions(+), 52 deletions(-) create mode 100644 backend/app/core/permissions.py create mode 100644 backend/app/routers/copilot.py create mode 100644 backend/app/services/ai_parser.py create mode 100644 backend/app/services/health_calculator.py create mode 100644 backend/app/services/llm_client.py create mode 100644 backend/app/services/risk_engine.py create mode 100644 backend/tests/test_health_calculator.py create mode 100644 backend/tests/test_permissions.py create mode 100644 backend/tests/test_risk_engine.py diff --git a/.env.example b/.env.example index ca88cd2..81b684d 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,11 @@ JWT_ALGORITHM=HS256 JWT_ACCESS_TOKEN_TTL_MINUTES=120 JWT_REFRESH_TOKEN_TTL_DAYS=7 -# ===== AI ===== -OLLAMA_BASE_URL=http://localhost:11434 -OLLAMA_MODEL=qwen2.5:7b +# ===== AI / LLM(千问 DashScope) ===== +LLM_API_KEY=your-dashscope-api-key +LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +LLM_MODEL=qwen-plus +LLM_TIMEOUT_SECONDS=60 # ===== 前端 ===== NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 622cf11..b4fbc1b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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"} diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py new file mode 100644 index 0000000..409ddcb --- /dev/null +++ b/backend/app/core/permissions.py @@ -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} diff --git a/backend/app/main.py b/backend/app/main.py index b737330..64f0f06 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/routers/copilot.py b/backend/app/routers/copilot.py new file mode 100644 index 0000000..1c4dba7 --- /dev/null +++ b/backend/app/routers/copilot.py @@ -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"<<>>\n{req.message}\n<<>>"}, + ] + + 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", + }, + ) diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py index 5e6edf4..e442554 100644 --- a/backend/app/routers/reports.py +++ b/backend/app/routers/reports.py @@ -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<<>>\n{report.raw_content or '无内容'}\n<<>>" + + 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", + }, + ) diff --git a/backend/app/services/ai_parser.py b/backend/app/services/ai_parser.py new file mode 100644 index 0000000..cf2ca7b --- /dev/null +++ b/backend/app/services/ai_parser.py @@ -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} 月报内容: + +<<>> +{content} +<<>> +""" + + +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, + } diff --git a/backend/app/services/health_calculator.py b/backend/app/services/health_calculator.py new file mode 100644 index 0000000..e2effc2 --- /dev/null +++ b/backend/app/services/health_calculator.py @@ -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" diff --git a/backend/app/services/llm_client.py b/backend/app/services/llm_client.py new file mode 100644 index 0000000..50ccfa2 --- /dev/null +++ b/backend/app/services/llm_client.py @@ -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() diff --git a/backend/app/services/risk_engine.py b/backend/app/services/risk_engine.py new file mode 100644 index 0000000..5f84cfa --- /dev/null +++ b/backend/app/services/risk_engine.py @@ -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 diff --git a/backend/tests/test_health_calculator.py b/backend/tests/test_health_calculator.py new file mode 100644 index 0000000..2c7cf24 --- /dev/null +++ b/backend/tests/test_health_calculator.py @@ -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" diff --git a/backend/tests/test_permissions.py b/backend/tests/test_permissions.py new file mode 100644 index 0000000..1b32221 --- /dev/null +++ b/backend/tests/test_permissions.py @@ -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 diff --git a/backend/tests/test_risk_engine.py b/backend/tests/test_risk_engine.py new file mode 100644 index 0000000..144a464 --- /dev/null +++ b/backend/tests/test_risk_engine.py @@ -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" diff --git a/docs/2-task.md b/docs/2-task.md index 5331ca6..f15a2e2 100644 --- a/docs/2-task.md +++ b/docs/2-task.md @@ -50,62 +50,62 @@ ## Phase 1:MVP 核心功能 ### T1.1 认证与权限 -- [ ] `backend/app/models/user.py`(User + Role + Tenant) -- [ ] `backend/app/routers/auth.py`(登录 / 注册 / refresh token) -- [ ] `backend/app/core/security.py`(JWT + 密码哈希) +- [x] `backend/app/models/user.py`(User + Role + Tenant) +- [x] `backend/app/routers/auth.py`(登录 / 注册 / refresh token) +- [x] `backend/app/core/security.py`(JWT + 密码哈希) - [ ] `backend/app/core/permissions.py`(角色级 + 字段级权限中间件) -- [ ] `backend/tests/test_auth.py`(RED) -- [ ] `frontend/src/app/login/page.tsx` -- [ ] `frontend/src/lib/auth.ts`(token 管理 + 自动刷新) -- 验证:登录 → 获取 token → 访问受保护 API +- [x] `backend/tests/test_auth.py`(11 tests GREEN) +- [x] `frontend/src/app/login/page.tsx`(登录表单 + AuthProvider) +- [x] `frontend/src/lib/auth-context.tsx`(token 管理 + 自动恢复) +- 验证:登录 → 获取 token → 访问受保护 API ✅ ### T1.2 企业档案管理 -- [ ] `backend/app/models/company.py`(CompanyProfile) -- [ ] `backend/app/routers/companies.py`(CRUD + 筛选) -- [ ] `backend/app/schemas/company.py`(Pydantic schema) -- [ ] `backend/tests/test_companies.py`(RED) -- [ ] `frontend/src/app/(investor)/companies/page.tsx`(列表 + 筛选) -- [ ] `frontend/src/app/(investor)/companies/[id]/page.tsx`(详情工作台骨架) -- [ ] `frontend/src/components/company/CompanyCard.tsx` -- 验证:创建企业 → 列表显示 → 详情页可访问 +- [x] `backend/app/models/company.py`(CompanyProfile) +- [x] `backend/app/routers/companies.py`(CRUD + 筛选 + 分页) +- [x] `backend/app/schemas/company.py`(Pydantic schema) +- [x] `backend/tests/test_companies.py`(11 tests GREEN) +- [x] `frontend/src/app/(investor)/companies/page.tsx`(列表 + 搜索 + 分页) +- [x] `frontend/src/app/(investor)/companies/[id]/page.tsx`(详情页) +- [ ] `frontend/src/components/company/CompanyCard.tsx`(独立组件抽取) +- 验证:创建企业 → 列表显示 → 详情页可访问 ✅ ### T1.3 月报管理 + AI 解析 -- [ ] `backend/app/models/report.py`(MonthlyReport + ReportItem) -- [ ] `backend/app/routers/reports.py`(提交 / 查看 / AI 解析) +- [x] `backend/app/models/report.py`(MonthlyReport) +- [x] `backend/app/routers/reports.py`(CRUD + 提交) - [ ] `backend/app/services/ai_parser.py`(月报 AI 解析服务) -- [ ] `backend/app/schemas/report.py` -- [ ] `backend/tests/test_reports.py`(RED) +- [x] `backend/app/schemas/report.py` +- [x] `backend/tests/test_reports.py`(7 tests GREEN) - [ ] `frontend/src/app/(founder)/reports/submit/page.tsx`(创始人提交月报) -- [ ] `frontend/src/app/(investor)/reports/page.tsx`(投资人查看月报) -- 验证:创始人提交月报 → AI 解析 → 投资人查看摘要 +- [x] `frontend/src/app/(investor)/reports/page.tsx`(投资人查看月报) +- 验证:CRUD + 提交 ✅ / AI 解析待实现 ### T1.4 健康度评分 -- [ ] `backend/app/models/health_score.py`(HealthScore + ScoreItem) +- [x] `backend/app/models/health_score.py`(HealthScore) - [ ] `backend/app/services/health_calculator.py`(财务 + 经营 + AI+ 专项) -- [ ] `backend/app/routers/health.py`(查询 / 重算) -- [ ] `backend/tests/test_health.py`(RED) +- [x] `backend/app/routers/dashboard.py`(聚合查询 + 评分列表) +- [x] `backend/tests/test_dashboard.py`(4 tests GREEN) - [ ] `frontend/src/components/health/HealthGauge.tsx`(仪表盘组件) - [ ] `frontend/src/components/health/HealthRadar.tsx`(雷达图组件) - [ ] `frontend/src/components/health/HealthTrend.tsx`(趋势 sparkline) -- 验证:月报数据 → 自动算分 → 前端展示仪表盘 + 雷达图 +- 验证:仪表盘 API ✅ / 自动算分 + 图表待实现 ### T1.5 投资机构驾驶舱 -- [ ] `backend/app/routers/dashboard.py`(聚合数据接口) -- [ ] `backend/tests/test_dashboard.py`(RED) -- [ ] `frontend/src/app/(investor)/page.tsx`(驾驶舱首页) +- [x] `backend/app/routers/dashboard.py`(聚合数据接口) +- [x] `backend/tests/test_dashboard.py`(4 tests GREEN) +- [x] `frontend/src/app/(investor)/dashboard/page.tsx`(驾驶舱首页 KPI + 概览) - [ ] `frontend/src/components/dashboard/HealthDistribution.tsx` - [ ] `frontend/src/components/dashboard/RiskSummary.tsx` - [ ] `frontend/src/components/dashboard/AIWeeklyBrief.tsx` -- 验证:登录后看到驾驶舱,数据来自后端 +- 验证:驾驶舱基础版 ✅ / 高级组件待实现 ### T1.6 风险预警 -- [ ] `backend/app/models/risk.py`(RiskEvent + Evidence) +- [x] `backend/app/models/risk.py`(RiskEvent) - [ ] `backend/app/services/risk_engine.py`(指标越界检测引擎) -- [ ] `backend/app/routers/risks.py`(列表 / 处理 / 关闭) -- [ ] `backend/tests/test_risks.py`(RED) -- [ ] `frontend/src/app/(investor)/risks/page.tsx`(风险工作台) +- [x] `backend/app/routers/risks.py`(列表 / 更新 / 删除) +- [x] `backend/tests/test_risks.py`(6 tests GREEN) +- [x] `frontend/src/app/(investor)/risks/page.tsx`(风险工作台) - [ ] `frontend/src/components/risk/RiskCard.tsx` + `RiskTimeline.tsx` -- 验证:指标越界 → 自动生成风险 → 工作台展示 → 处理闭环 +- 验证:CRUD + 状态流转 ✅ / 自动检测引擎待实现 ### T1.7 投后报告 - [ ] `backend/app/services/report_generator.py`(AI 报告生成) diff --git a/progress.txt b/progress.txt index 84f3c59..4e84d45 100644 --- a/progress.txt +++ b/progress.txt @@ -64,16 +64,12 @@ |---|---|---| | T1.1 认证与权限 | ✅ | 后端 15 tests passed + 前端登录页构建成功 | | T1.2 企业档案 CRUD | ✅ | 后端 11 tests passed + 前端列表/详情页构建成功 | +| T1.3 月报管理 | ✅ | 后端 7 tests passed + 前端月报列表页构建成功 | +| T1.4 健康度仪表盘 | ✅ | 后端 4 tests passed + 前端驾驶舱首页构建成功 | +| T1.5 风险工作台 | ✅ | 后端 6 tests passed + 前端风险列表页构建成功 | -### T1.1 产出 +### Phase 1 总计 -- **后端**:`auth.py` 路由(register/login/refresh/me)+ `dependencies.py`(JWT 校验 + 角色权限) -- **前端**:`auth-context.tsx`(AuthProvider)+ 登录表单页 -- **测试**:11 个认证测试(注册/登录/获取用户/刷新 token)+ 4 个健康检查 = 15 passed - -### T1.2 产出 - -- **后端**:`companies.py` 路由(列表/详情/创建/更新/删除)+ 分页 + 关键词搜索 + 租户隔离 -- **前端**:`companies.ts` API 客户端 + 企业列表页(卡片+搜索+分页)+ 企业详情页 -- **测试**:11 个企业 CRUD 测试(创建/列表/搜索/分页/详情/更新/删除) -- **总计**:26 tests passed +- **后端测试**:43 tests passed(auth 11 + companies 11 + reports 7 + dashboard 4 + risks 6 + health 4) +- **前端路由**:13 个页面全部构建成功 +- **API 端点**:auth(4)+ companies(5)+ reports(6)+ dashboard(2)+ risks(5)= 22 个