Compare commits

...

42 Commits

Author SHA1 Message Date
selfrelease 50cc92c2d7 docs: 投后经理绩效管理方案 — 八大维度+数据支持明细+行业最佳实践对标 2026-08-07 14:09:38 +08:00
selfrelease 3bae5fbfc1 feat(backend): update dashboard router, seed demo data, and investor agents page 2026-07-21 18:05:49 +08:00
selfrelease af91d843d8 feat(frontend): 全部页面接入后端API并同步全局企业选择
- ai-plus: Class组件→函数组件,接入 listHealthScores + listAgentExecutions API
- ooda: 硬编码→接入 risks/weak-signals/sentinels/tasks API 构建OODA各阶段
- threads: 硬编码→接入 risks/tasks/events API 组合决策线程
- today: 硬编码→接入 risks/weak-signals/synergies/reports API 构建行动项
- compare: 硬编码企业名→使用全局企业列表 + listHealthScores + listRisks API
- innovation/knowledge-graph/portfolio: 加 useCompanyScope 标题显示企业名
- 所有页面标题在单企业选择时显示企业名
- 编译验证通过
2026-07-20 08:40:54 +08:00
selfrelease 006fd7de7e feat(frontend): 全局企业筛选同步 — 各页面显示企业名标签
- 新增 useCompanyName hook 和 CompanyNameTag 组件
- 20+ investor 页面添加 CompanyNameTag 显示企业名
- 表格型页面(agents/agreements/board/weak-signals/talents/reports)新增企业列
- 卡片型页面(risks/synergies/sentinels/alpha/exit-signals/okrs/aars/events/tasks/pre-mortems/nudges/product-diagnostics/peer-circles)添加企业名标签
- customer-success 改用 CompanyNameTag 替代手动 company_name 显示
- PlanCard 组件添加企业名标签
- 编译验证通过
2026-07-20 08:22:24 +08:00
selfrelease c3232f73c8 fix(scope): all pages react to company scope change via useScopeEffect
- Created useScopeEffect hook that auto-triggers reload on scope change
- Replaced useEffect([], []) with useScopeEffect in 20+ pages
- Risks/Reports/Dashboard also use useScopeEffect
- Removed redundant per-page useCompanyScope where only effect was needed
2026-07-20 08:09:33 +08:00
selfrelease 4265f4bc4c fix(dashboard): total_companies returns 1 in single-company scope; KPI labels adapt 2026-07-20 07:50:39 +08:00
selfrelease e81aa6828e feat(scope): add company scope switcher — two views (by-task all companies / by-company all tasks)
- CompanyScopeContext + Provider with localStorage persistence
- Sidebar company selector (desktop + mobile)
- apiFetch auto-injects company_id on GET requests
- Dashboard summary API supports company_id filter
- Milestones/Financial/DigitalTwins pages use scope instead of manual input
- HealthHeatmap/HealthTrends components react to scope changes
2026-07-20 07:47:53 +08:00
selfrelease 8b8926bde3 refactor(workbench): remove unused WorkModeSwitcher — workbench is always focus mode 2026-07-19 22:03:42 +08:00
selfrelease 3d22bd8a3d feat(workbench): complete company view — health trend, major events, milestones, team, synergy + dashboard click-through 2026-07-19 22:01:49 +08:00
selfrelease 0197f89018 feat(dashboard): add company filter — switch between portfolio overview and single company view 2026-07-19 21:52:40 +08:00
selfrelease a24ccd01c7 fix(dashboard): trends per-company lines instead of meaningless average 2026-07-19 21:47:19 +08:00
selfrelease 7b0b538b37 fix(dashboard): stack heatmap and trends vertically (full width each) 2026-07-19 21:41:07 +08:00
selfrelease 8138fb5540 fix(dashboard): display company name instead of ID in health score overview 2026-07-19 21:39:49 +08:00
selfrelease 4980ebcd30 feat(frontend): prefill demo credentials on login page + E2E test passed 2026-07-19 21:35:32 +08:00
selfrelease 4851b7e427 test(frontend): evaluation E2E tests - 4 passed (template config + compare view + sidebar nav) 2026-07-19 21:33:25 +08:00
selfrelease e3ec98f63a feat(frontend): Phase 6 evaluation template config page + score compare view 2026-07-19 21:24:36 +08:00
selfrelease de53a252e4 feat(backend): 6-axis dynamic evaluation system with weight engine and template management 2026-07-19 20:59:31 +08:00
selfrelease f4ddcab2ca test(frontend): UIUX E2E 测试 — 新增路由导航/创始人端/Admin端/工作台/移动端适配
新增 6 个 E2E 测试文件,覆盖 2-task-uiux.md 全部 50 项任务:
- uiux-navigation.spec.ts: 8 tests (today/compare/threads/workspace/ooda/ai-plus/profiles)
- sidebar-navigation.spec.ts: 6 tests (投资人 Sidebar 6 业务域)
- founder-uiux.spec.ts: 7 tests (创始人端 6 域导航)
- admin-uiux.spec.ts: 3 tests (Admin 6 管理域 + 商业秘密保护)
- workbench-uiux.spec.ts: 5 tests (Highlights/WorkMode/Tab/InsightRail)
- mobile-uiux.spec.ts: 5 tests (移动端抽屉导航 + 创始人底部导航)

同时修复全部 no-explicit-any 警告,替换为 TypeScript 接口定义。

测试结果: 41 E2E passed, 405 backend passed
2026-07-19 20:37:25 +08:00
selfrelease 3a905da35b feat(frontend): P5+P6 多主体画像 + 移动端抽屉导航
P5 多主体画像:
- /profiles 页面:根据角色展示不同视角(GP/投后负责人/投资经理/创始人/管理员)

P6 移动端适配:
- 投资人端 Sidebar 折叠为抽屉导航(Menu 按钮触发 + 遮罩层 + 6 域完整导航)
- 移动端导航点击后自动关闭抽屉
2026-07-19 19:10:38 +08:00
selfrelease 778c2e8c5c feat(frontend): P4 #38-41 Focus TOC/BML 可视化 + AI+ 看板
- TOCVisualization: 约束理论瓶颈节点识别可视化
- BMLDashboard: 信念/心智模型/学习认知追踪仪表盘
- AI+ 专项看板: 商业化/模型成本/数据合规三维 + Agent 运行状态
2026-07-19 19:08:59 +08:00
selfrelease 44cbdc9d62 feat(frontend): P3+P4 创始人端增强 + 企业战情室集成
P3 创始人端:
- 经营驾驶舱: 6 KPI 卡片 + 风险提示
- 投资人沟通准备中心: 会议列表 + 准备清单 + AI 建议
- 里程碑自填: 进度滑块 + 新增表单
- OKR 对齐视图: 投资人期望对齐度 + KR 进度
- BML 认知追踪: 信念/心智模型/学习追踪
- 通知页面 + 个人中心页面

P4 企业战情室集成:
- HighlightsPanel: 顶部 KPI 摘要栏
- WorkModeSwitcher: 4 种工作模式切换
- InsightRail: 右侧 AI 面板(默认风险预警 Agent)

Admin:
- 商业秘密保护配置: 密级/留痕/导出管控/审计日志
2026-07-19 19:06:43 +08:00
selfrelease a4044baa31 feat(frontend): P2 页面开发 — 今日行动中心 + 对比 + 决策线程 + OODA + 助推追踪 + 密级标签
- 今日行动中心: 6 区块结构(AI早报/必须处理/建议关注/增长机会/等待他人/已完成)
- 多企业对比: 集成 CompareMode 组件 + 企业选择器
- 决策线程列表/详情: 集成 ThreadList/ThreadDetail 组件
- OODA 决策循环: 四阶段可视化 + 决策延迟追踪
- 助推效果追踪: 采纳率/行动率/疲劳度指标面板
- 信息分级标识: 4 级密级标签组件(公开/内部/机密/绝密)
2026-07-19 19:02:32 +08:00
selfrelease 129210405d feat(frontend): P1 核心 UI 组件 — Context Bar + Work Mode + Insight Rail + 工作区 + 决策线程
- ContextBar: Scope/Lens/Time 三维切换器 + Action Filter
- ContextBarProvider: 全局 Context + URL 参数双向同步
- WorkModeSwitcher: Overview/Compare/Focus/Queue 四种模式
- FocusMode: 全屏沉浸分析 + TOC/BML 入口
- QueueMode: 左右分栏 Split View
- CompareMode: 2-5 企业并排对比 + 差异高亮
- InsightRail: 右侧可折叠 AI 面板 + 12 Agent 差异化卡片
- WorkspaceTabs: 多 Tab 管理(最多 8 个)
- HighlightsPanel: 企业战情室顶部 KPI 摘要
- ThreadList + ThreadDetail: 决策线程列表/详情 + 状态机时间线
2026-07-19 18:56:46 +08:00
selfrelease 956270d14d feat(frontend): P0 导航架构重构 — 6 业务域 + 角色化排序 + 新路由
- 投资人 Sidebar: 5 扁平分组 → 6 业务域(今日/组合/工作/增长/洞察/报告)
- 角色化导航: GP→组合优先, 投后负责人→今日优先, 投资经理→今日+组合
- 新增路由: /today /compare /threads /threads/[id] /workspace
- Admin: 单页标签 → 6 管理域 Sidebar(租户/用户/审计/数据源/安全/设置)
- 创始人: 3 底部导航 → 6 域(今日/经营/月报/Copilot/通知/我的)
- 新增 navConfig.ts 统一管理导航配置
2026-07-19 18:51:38 +08:00
selfrelease 947642919b feat(data): 完整演示种子数据脚本 — 22 张表 119 条记录
- 租户:远见资本(VC)
- 用户 7 个:admin/gp/lead/mgr/founder_a/founder_b/founder_c
- 企业 5 家:智链科技/云栈数据/深瞳智能/量子芯微/光合生物
- 覆盖全模型:财务数据/月报/健康度/风险/弱信号/任务/OKR/里程碑/
  协同/决策前哨/助推/重大事项/协议/董事会/BML假设/AAR/团队/人才/审计
- 运行:cd backend && python -m scripts.seed_demo_data
- 登录密码均为 demo123456
2026-07-19 12:05:33 +08:00
selfrelease fad458b2a7 docs(uiux): UIUX 设计方案大改 + 5 份作业指导书对齐 + 开发任务文档
- UIUX 文档:填充 19 个缺口(多主体画像/健康度/AI+看板/增长域/洞察域/创始人端/OODA/助推/商密)
- UIUX 文档:插入 6 个新章节(十四~十九),旧章节重编号为二十~三十一,更新目录和交叉引用
- 作业指导书 x5:导航改为 6 域分组,新增 Context Bar/工作模式/Insight Rail/决策线程/多工作区等 UI 概念
- 新建 docs/2-task-uiux.md:50 个代码落地开发任务,按 P0-P6 分优先级 + 8 Sprint 规划
- 后端/前端:大量新增模型、路由、组件(来自之前 Phase 开发)
2026-07-19 11:53:38 +08:00
selfrelease 734a16a7f3 docs: 全面对齐 v2.0 方案 — 重写 0-req/1-prd/2-task 三份文档
- 0-req.md: 扩充 Phase 2-4 需求、修正弱信号分期、补充多主体画像/OODA/客户成功/边缘算力等概念
- 1-prd.md: 补充九层架构、23+ Agent 体系、Phase 1-4 完整数据模型、健康度 14 维度、方法论驱动设计、缺失 UI 页面、安全权限设计、边缘算力部署
- 2-task.md: 对齐 v2.0 §17 四期路径,Phase 2(12任务) + Phase 3(13任务) + Phase 4(15任务) 完整规划
2026-07-18 22:45:29 +08:00
selfrelease 2c429ca1a2 docs: Phase 2 + Phase 3 完整规划
Phase 2(深度功能 + 管理后台,~6 周):
- T2.1 企业详情工作台(多 Tab + 图表 + AI 建议抽屉 + 时间线)
- T2.2 Admin 管理后台(租户/用户/审计日志)
- T2.3 月报文件上传 + PDF 导出
- T2.4 创始人端完善(大数字卡片 + AI 辅助填充)
- T2.5 审计日志 + Redis 缓存
- T2.6 驾驶舱增强(热力图 + 多企业趋势对比)

Phase 3(智能化 + 生态扩展,~10 周):
- T3.1 RAG 知识库 + 语义搜索(PgVector)
- T3.2 智能预警 + 预测分析
- T3.3 多源数据接入(Crunchbase/工商/GitHub)
- T3.4 协作闭环 + 任务管理(Kanban + 评论 + 通知)
- T3.5 数据安全 + 合规加固(2FA/限流/脱敏/加密)
- T3.6 投后报告增强 + 定期推送(AI 季度报告 + 邮件推送)
2026-07-18 22:31:16 +08:00
selfrelease 5b34651e41 feat: 全部待完成项收尾 — Phase 1 100% 完成
新增组件:
- CompanyCard.tsx(企业卡片独立组件)
- RiskCard.tsx + RiskTimeline.tsx(风险卡片+时间线)
- AIWeeklyBrief.tsx(AI 周报 SSE 流式生成)
- RiskToastNotifier.tsx(风险预警 toast 轮询推送)
- reports/view/[id]/page.tsx(投后报告查看页+导出)

重构:
- 企业列表页使用 CompanyCard 组件
- 风险工作台使用 RiskCard 组件 + sonner toast 替换 alert/confirm
- 投资人布局添加 RiskToastNotifier

验证: 67 后端测试 + 15 前端路由全部构建成功
2026-07-18 22:27:27 +08:00
selfrelease 374981609b docs: 更新任务文档和进度 — Phase 1 全部完成
- 67 后端测试通过 / 14 前端路由构建成功
- 26 个 API 端点 / 千问 SSE 流式 AI 服务
- 任务清单 T1.1-T1.8 + T0.4 全部标记完成
2026-07-18 22:23:09 +08:00
selfrelease 63031dd183 feat: T1.7 投后报告导出 + T1.8 docker-compose.dev.yml + T0.4 补全
- 后端:reports_export 路由(summary 聚合 + JSON 导出)
- 测试:3 个导出测试,总计 67 passed
- docker-compose.dev.yml:开发覆盖(源码挂载 + hot reload + debugpy)
- 前端 14 路由构建成功
2026-07-18 22:21:51 +08:00
selfrelease 53ae2059c5 feat(frontend): AI 组件 + 创始人月报 + 健康度图表 + Copilot 浮动窗口
- 健康度图表:HealthRadar(雷达图)+ HealthGauge(仪表盘)+ HealthTrend(趋势)
- 驾驶舱组件:HealthDistribution + RiskSummary
- AI Copilot:浮动对话窗口(SSE 流式接收)
- 创始人月报提交页:表单 + 企业选择 + 年月选择
- 投资人月报 AI 解析页:SSE 流式输出 + 健康度雷达图 + 风险检测结果
- 前端构建 14 路由成功
2026-07-18 22:19:43 +08:00
selfrelease 7ec4fb0747 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
2026-07-18 22:16:40 +08:00
selfrelease 94be6189e9 feat: T1.5 风险工作台 — 后端 CRUD + 前端风险列表
- 后端:risks 路由(列表/详情/创建/更新/删除)+ 状态流转
- 前端:风险工作台页(卡片列表、状态筛选、内联状态切换、删除)
- 测试:6 个风险 CRUD 测试(总计 43 tests passed)
- 前端构建 13 路由成功
2026-07-18 22:06:30 +08:00
selfrelease 4432d47ff9 feat: T1.4 健康度仪表盘 — 后端聚合 API + 前端驾驶舱
- 后端:dashboard 路由(summary 聚合 + scores 列表)
- 前端:驾驶舱首页(KPI 卡片 + 健康度概览)
- 测试:4 个仪表盘测试(总计 37 tests passed)
- 前端构建 12 路由成功
2026-07-18 22:03:47 +08:00
selfrelease 353a52a401 feat(frontend): T1.3 月报管理页 — 投资人端列表 + 提交/删除
- 月报列表页:表格展示、状态标签、分页
- 操作:提交月报(draft→submitted)、删除月报
- API 客户端:reports.ts 封装 CRUD + submit
- 前端构建 12 路由成功
2026-07-18 22:01:35 +08:00
selfrelease 263f474c90 feat(backend): T1.3 月报管理 CRUD — 列表/详情/创建/更新/提交/删除
- 路由:GET/POST/PUT/DELETE /api/v1/reports + POST /reports/{id}/submit
- 状态机:draft → submitted → ai_parsed → reviewed
- 防重复:同年同月同企业不可重复创建
- 租户隔离:通过 company 关联校验
- 测试:7 个月报测试(总计 33 tests passed)
2026-07-18 22:00:34 +08:00
selfrelease 0bcaafd0eb feat(frontend): T1.2 企业列表 + 详情页 — 投资人端
- 企业列表页:搜索、分页、卡片展示
- 企业详情页:基本信息 + 业务描述
- API 客户端:companies.ts 封装 CRUD 函数
- 前端构建 11 路由成功
2026-07-18 21:58:47 +08:00
selfrelease 8fe8047429 feat(backend): T1.2 企业档案 CRUD — 列表/详情/创建/更新/删除
- 路由:GET/POST/PUT/DELETE /api/v1/companies
- 支持分页、关键词搜索、行业/阶段筛选
- 租户隔离:只能操作本租户企业
- 测试:11 个企业 CRUD 测试,全部 passed(总计 26 tests)
2026-07-18 21:57:02 +08:00
selfrelease a95f63c24c feat(frontend): T1.1 前端登录页对接 — AuthProvider + 登录表单
- AuthProvider:管理 token 和用户状态,自动恢复登录态
- 登录页:邮箱密码表单,对接后端 /auth/login API
- 根布局包裹 AuthProvider
- 前端构建 8 路由全部成功
2026-07-18 21:55:12 +08:00
selfrelease 2be0778ec7 feat(backend): T1.1 认证与权限 — 注册/登录/刷新/获取用户
- 后端:auth 路由(register/login/refresh/me)+ JWT + bcrypt 密码哈希
- 依赖注入:get_current_user + require_role 角色权限校验
- 跨数据库兼容:JSONBType(PG 用 JSONB,SQLite 用 JSON)
- 测试:11 个认证测试 + 4 个健康检查测试 = 15 passed
2026-07-18 21:54:06 +08:00
selfrelease 51feae55ba feat(backend): Phase 0 项目骨架完成 — 后端/前端/数据库/Docker
- 后端:FastAPI + SQLAlchemy + Alembic,7 张核心表迁移成功
- 前端:Next.js 16 + TailwindCSS 4 + 三端布局(投资人/创始人/Admin)
- 数据库:PostgreSQL 16,7 张核心实体表(tenants/users/companies/monthly_reports/health_scores/risk_events/audit_logs)
- Docker:docker-compose.yml + 前后端 Dockerfile
- 测试:健康检查 4 个测试全部 GREEN
- 文档:README/run.md/AGENTS.md/docs 体系完整
2026-07-18 21:50:15 +08:00
398 changed files with 47567 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# 环境变量示例
# 复制为 .env 并修改实际值
# ===== 数据库 =====
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/aiportpilot
# ===== Redis =====
REDIS_URL=redis://localhost:6379/0
# ===== JWT =====
JWT_SECRET_KEY=change-me-in-production
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_TTL_MINUTES=120
JWT_REFRESH_TOKEN_TTL_DAYS=7
# ===== 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
# ===== 应用 =====
APP_ENV=development
APP_DEBUG=true
APP_LOG_LEVEL=info
+77
View File
@@ -0,0 +1,77 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
/lib/
/lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environment
.venv/
venv/
ENV/
# UV
uv.lock
# Node.js
node_modules/
.pnpm-store/
# Next.js
.next/
out/
build/
next-env.d.ts
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Docker
docker-compose.override.yml
# Database
*.sql.bak
backups/
# Logs
*.log
logs/
# Test
.coverage
htmlcov/
.pytest_cache/
coverage/
# Misc
*.tmp
*.bak
+48
View File
@@ -0,0 +1,48 @@
# AGENTS.md — Agent 协作规则
## 核心原则
1. **先想再写**:任何代码变更前,确认当前处于方法论七步流程的哪一步
2. **先测再码**TDD 强制,RED-GREEN-REFACTOR
3. **小步快跑**:任务粒度 2-5 分钟,每个 RED-GREEN 循环提交一次
4. **不删旧代码**:修改时保留无关代码,用快照隔离历史
5. **中文沟通**:所有对话、注释、文档使用中文
## 分工
| Agent | 职责 | 触发时机 |
|---|---|---|
| Brainstorm Agent | 需求澄清、方案探索 | 新功能提出时 |
| Plan Agent | 任务拆解、文件路径规划 | 设计批准后 |
| TDD Agent | 写测试 → 写实现 → 重构 | 任务执行时 |
| Review Agent | 代码审查、规格符合度检查 | 每个任务完成后 |
| Debug Agent | 根因分析、复现测试 | Bug 出现时 |
## 禁止事项
- 禁止跳过测试直接写代码
- 禁止删除无关代码
- 禁止 `alert()/confirm()`
- 禁止硬编码 hex 色值
- 禁止多 UI 库混用
- 禁止 `@skip/it.only` 进 PR
- 禁止 mock DB 跑集成测试
## 提交规范
```
type(scope): description
type ∈ feat/fix/refactor/test/docs/chore
scope ∈ frontend/backend/db/docs/infra
```
## 文件路径约定
- 前端页面:`frontend/src/app/(role)/page.tsx`
- 前端组件:`frontend/src/components/`
- 后端路由:`backend/app/routers/`
- 后端模型:`backend/app/models/`
- 后端服务:`backend/app/services/`
- 测试:`backend/tests/` / `frontend/__tests__/`
- 文档:`docs/`
+244
View File
@@ -0,0 +1,244 @@
# 应用构建方法论 v3.0(融合 Superpowers
> 基于 v2.5 + [obra/superpowers](https://github.com/obra/superpowers) 流程纪律,针对 AIPortPilot 项目优化。
> 核心理念:**结构化 > 自由发挥 / 快照 > 覆盖 / 演进 > 重写 / 先想再写 / 先测再码**
---
## 一、七步开发流程(强制执行)
每一步都是**必须执行的工作流**,不是建议。Agent 在任何任务前先检查当前处于哪一步。
### Step 1Brainstorming(头脑风暴)
**触发**:用户提出新功能/新模块需求时
**动作**
- 不急着写代码,先苏格拉底式提问
- 探索替代方案,权衡取舍
- 分段展示设计,每段短到用户能读完就消化
- 产出:`docs/1-prd.md`(产品设计文档)
**完成标志**:用户对 PRD 签字确认
### Step 2Design Document(设计文档)
**触发**PRD 签字后
**动作**
- 技术架构设计(技术栈、数据模型、API 设计、UI/UX 方案)
- 产出:`docs/1-prd.md` 中的技术设计章节
**完成标志**:用户对技术方案签字确认
### Step 3Writing Plans(任务拆解)
**触发**:设计批准后
**动作**
- 将工作拆成 **2-5 分钟** 的小任务
- 每个任务有:精确文件路径、完整代码描述、验证步骤
- 任务之间无循环依赖,可并行标注
- 产出:`docs/2-task.md`(任务清单)
**完成标志**:用户对任务清单签字确认
### Step 4Feature Branch(特性分支)
**触发**:任务清单批准后
**动作**
- `git checkout -b feature/{module-name}`
- 确保干净测试基线
**完成标志**:分支创建成功
### Step 5TDD Implementation(测试驱动实现)
**触发**:分支创建后
**动作**
- **RED**:先写失败测试,运行确认失败
- **GREEN**:写最小代码让测试通过
- **REFACTOR**:重构,保持测试绿色
- **COMMIT**:每个 RED-GREEN 循环提交一次
- **禁止**:先写代码后补测试
**完成标志**:所有任务测试通过
### Step 6Code Review(代码审查)
**触发**:每个任务完成后
**动作**
- 对照计划检查规格符合度
- 检查代码质量(命名、结构、安全、性能)
- 严重问题阻断进度,必须修复后继续
**完成标志**:审查通过
### Step 7Finishing Branch(收尾合并)
**触发**:所有任务完成且审查通过
**动作**
- 运行全量测试
- 提供选项:合并到 master / 创建 PR / 保留分支 / 丢弃
- 合并后清理分支
**完成标志**:代码进入 master
---
## 二、三原则
1. **结构化 > 自由发挥**:先文档后代码,先设计后实现
2. **快照 > 覆盖**:用 `*_snapshot` JSON 保存历史,不删旧数据
3. **演进 > 重写**:版本号 + `is_current` 指针,增量演进
---
## 三、文档骨架(必有)
| 文件 | 内容 | 何时写 |
|---|---|---|
| `README.md` | 项目介绍、技术栈、快速启动 | Step 2 |
| `run.md` | 10 板块运维手册 | Step 2 |
| `AGENTS.md` | Agent 协作规则 | Step 2 |
| `docs/0-req.md` | 需求文档(从方案文档提炼) | Step 1 |
| `docs/1-prd.md` | PRD + 技术设计 | Step 1-2 |
| `docs/2-task.md` | 任务清单(2-5 min 粒度) | Step 3 |
| `docs/daily/` | 日报 | 每日 |
### run.md 十板块
技术栈 / 首次准备 / 基础设施启停 / 应用启停 / DB 命令 / 排错 / 端口表 / env / 部署备份 / FAQ
### 铁律
- 命令可复制粘贴
- 标注 `[Docker]` / `[Native]`
- 危险操作标红
- 版本号写死
- 过时即同步
---
## 四、数据 8 铁律
1. 快照隔离历史(`*_snapshot` JSON
2. 版本号 + `is_current` 唯一指针,不删旧
3. 内部状态机与用户可见状态分离
4. 租户隔离走 `session.tenant_id`,不信任请求体
5. 配置粒度对齐"谁应该决定"(全局/租户/用户)
6. 字段演进:nullable + 默认值;枚举用字符串;时间戳 `*_at`
7. 审计字段:`created_by/updated_by/deleted_by + *_at`;关键操作落 `audit_logs` ≥ 6 月
8. UTC 存储;金额用 decimal/整数;禁止 float
---
## 五、安全与权限
- 密钥进 KMS/Vault**绝不进 Git**,仅 commit `.env.example`
- 后端必须独立校验权限,前端隐藏 ≠ 后端放权
- PII 全链路脱敏(手机/身份证/邮箱),日志中用 `138****1234`
- 注入防御:SQL 参数化 / 命令 `shell=False` / XSS 自动转义 + CSP / CSRF 走 SameSite
- 限流熔断:登录 5次/min/IP,写接口按用户限流,429 带 `Retry-After`
- JWT 短 TTL(≤2h+ refreshCookie 必 HttpOnly+Secure+SameSite
- **本项目特殊**:投后数据高度敏感,优先私有化部署,数据不出域
---
## 六、API 设计
- REST 资源命名(名词复数 + 层级),版本进 URL 不进 query
- HTTP 状态码语义化,禁全 200 塞 error
- 统一响应壳:`{code, message, data, trace_id, timestamp}`
- 分页 `?page&page_size&sort&filter[k]=v`,大数据集用 keyset 游标
- 写接口接受 `Idempotency-Key`,订单/支付**必须**
- `trace_id` 全链路(网关→后端→DB→前端 `X-Trace-Id`
- OpenAPI 自动生成进 Git,废弃接口 `Deprecation` + `Sunset`
---
## 七、AI / 智能集成
- 接口抽象,业务面对自家"智能服务接口"
- 多源容灾 + 兜底降级(规则/缓存/默认),不拖垮主流程
- 强制 Structured Output / JSON Schema,禁编造
- 决策证据化:输出 `score / confidence / evidence / concerns / fallback_used`
- Prompt 进 Git,不只在 DB;可 diff、可回滚
- 单次/用户/租户分级成本预算;缓存优先;慢路径异步化
- 用户输入与系统 prompt **分离 role**,加分隔符 `<<<USER_INPUT>>>`,防注入
- LLM 输出代码绝不直接 exec/eval,必经语法检查 + 沙箱
- PII 进 LLM 前脱敏,响应再回填
- `confidence < 0.6` 显式提示人工核对
---
## 八、客户端 + 多端 UIUX
### 三端角色化
- **投资人端(B 端专业)**:主色 `gray-900`,左 Sidebar`w-52 sticky`+ 内容 `bg-[#f8f9fb]`,信息密集
- **创始人端(C 端温暖)**:主色 `indigo-600`,顶部 sticky Header`h-14 bg-white/95 backdrop-blur`),渐变背景
- **Admin 端(警示)**Header `bg-slate-950` + 主色 `amber-400`,内容 `bg-slate-100`,必带 ADMIN 徽章
### 共享 Token
- Geist 字体 / oklch 色彩 / `--radius: 0.625rem`**禁硬编码 hex**
- shadcn + @base-ui + lucide + sonner + recharts**禁多 UI 库混用**
### Layout 统一
- 容器 `container mx-auto max-w-7xl px-4`Header 全应用 `h-14`
### 交互统一
- `<LoadingSpinner>` / `<EmptyState>` / sonner toast / `<Dialog>` 二次确认
- 状态色三件套:`emerald(success) / amber(warning) / rose(destructive)`
### 响应式
- sm/md/lg/xl/2xl 五断点
- Sidebar < md 折叠为抽屉/下拉,不丢功能入口
### a11y
- 键盘可达 / 对比度 ≥ 4.5:1 / 语义化标签 / aria-label**禁 `<div onClick>`**
### 其他
- i18n:文案进 JSON,用 `Intl.*` 格式化
- 报告页强制 `@media print`
- 流式输出 RAF 批量刷新,禁每 token 触发 React 重渲染
- **禁 `alert()/confirm()`、禁多 UI 库、禁硬编码主色 hex、禁三端共用 Header**
---
## 九、五防一兜底
1. 防异步穿越(AbortController / 引用保存)
2. 防外部单点(超时+重试+熔断+多源)
3. 防资源缺失(启动校验字体/翻译/配置)
4. 防 API 弃用(季度内替换 + 灰度升级)
5. 防数据丢失(事务 / 幂等键 / 持久化队列 / beforeunload
6. 一兜底:最差体验是"功能受限可用",不是白屏
---
## 十、部署运维
- 部署脚本"只清自己",禁 `pm2 delete all`、禁 `redis-cli FLUSHALL`、禁 `rm -rf /`
- 跨子域 cookie 名独立 + `domain=.example.com`
- DB migrate 走 CI/CD 自动应用
- 备份:每天 2 次 + 滚动 7 天 + 双副本(本机 + 异地)+ 每月恢复演练
- 监控 4 金指标:Latency / Traffic / Errors / Saturation
- 灰度 1% → 10% → 50% → 100%,每档观察 ≥ 30 分钟
- 回滚 SOP 必备,回滚比修复快
---
## 十一、测试纪律
- **70/20/10**:单元/集成/E2E
- **TDD 强制**RED-GREEN-REFACTOR,先写失败测试再写代码
- 关键路径必覆盖(登录/支付/导出/权限/AI 主流程)
- 集成测试用 testcontainers 跑真实 DB,禁 mock DB
- 修 bug 必先写复现测试,禁删测试、禁 `@skip/it.only` 进 PR
---
## 十二、协作纪律
- 每步完成后更新 `docs/2-task.md` 状态
- 日报写 `docs/daily/YYYY-MM-DD.md`
- 重大决策记 `docs/decisions/`ADR 格式)
- 代码审查问题按严重度分级:Critical(阻断)/ Major(必须修)/ Minor(建议)
- 提交信息格式:`type(scope): description`type ∈ feat/fix/refactor/test/docs/chore
+63
View File
@@ -0,0 +1,63 @@
# AIPortPilot — AI+ Portfolio Operating System
> AI 投后管理与组合协同平台:投资人和创始人的共同操作系统。
> 对投资收益负责,主动创造增长,持续进化管理知识。
## 技术栈
| 层 | 技术 | 版本 |
|---|---|---|
| 前端 | Next.js + React + TypeScript | 15.x / 19.x / 5.x |
| UI | TailwindCSS + shadcn/ui + lucide + recharts | 4.x |
| 后端 | Python + FastAPI | 3.12 / 0.115.x |
| 数据库 | PostgreSQL + Redis | 16 / 7.x |
| 向量库 | PgVector (PostgreSQL 扩展) | — |
| AI | Ollama (本地推理) + LangChain | — |
| 部署 | Docker Compose → K8s | — |
## 快速启动
```bash
# [Docker] 一键启动全部基础设施
docker compose up -d
# [Native] 后端
cd backend && uv sync && uv run uvicorn app.main:app --reload --port 8000
# [Native] 前端
cd frontend && pnpm install && pnpm dev
```
详见 [run.md](./run.md)。
## 项目结构
```
AIPortPilot/
├── frontend/ # Next.js 前端(投资人端 + 创始人端 + Admin)
├── backend/ # FastAPI 后端
├── docs/ # 文档(需求 / PRD / 任务 / 日报)
├── docker-compose.yml # 开发环境编排
├── BUILD-METHODOLOGY.md # 构建方法论 v3.0
└── AI+投后管理方案v2.0.md # 原始方案文档
```
## MVP 范围(Phase 1
- 企业档案管理
- 月报在线提交 + AI 解析
- 通用健康度评分(财务 + 经营)
- AI+ 专项健康度(商业化 + 成本)
- 投资机构驾驶舱
- 风险预警(指标越界)
- 投后报告自动生成
- 移动端:风险预警推送 + AI Copilot 基础问答
## 文档
- [构建方法论](./BUILD-METHODOLOGY.md)
- [运维手册](./run.md)
- [Agent 协作规则](./AGENTS.md)
- [需求文档](./docs/0-req.md)
- [PRD + 技术设计](./docs/1-prd.md)
- [任务清单](./docs/2-task.md)
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple \
fastapi uvicorn[standard] sqlalchemy[asyncio] asyncpg alembic \
pydantic pydantic-settings python-jose[cryptography] passlib[bcrypt] \
python-multipart redis httpx structlog
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+77
View File
@@ -0,0 +1,77 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from app.core.config import settings
from app.core.database import Base
import app.models # noqa: F401 — 导入所有模型以便 Alembic 发现
config = context.config
# 使用项目配置的数据库 URL(同步驱动)
config.set_main_option("sqlalchemy.url", settings.database_url.replace("+asyncpg", "+psycopg2"))
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,161 @@
"""create core tables: tenants users companies reports health_scores risks audit_logs
Revision ID: 278c8cfa6042
Revises:
Create Date: 2026-07-18 21:49:38.409216
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '278c8cfa6042'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tenants',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False, comment='租户名称'),
sa.Column('type', sa.String(length=50), nullable=False, comment='租户类型:vc/cvc/gov/holdings'),
sa.Column('config_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='租户配置'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('companies',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False, comment='企业名称'),
sa.Column('industry', sa.String(length=100), nullable=True, comment='行业'),
sa.Column('stage', sa.String(length=50), nullable=True, comment='融资阶段:seed/a/b/c/ipo'),
sa.Column('logo_url', sa.String(length=500), nullable=True),
sa.Column('description', sa.Text(), nullable=True, comment='业务描述'),
sa.Column('founded_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('total_funding', sa.String(length=50), nullable=True, comment='累计融资额'),
sa.Column('website', sa.String(length=500), nullable=True),
sa.Column('extra_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='扩展字段'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_companies_tenant_id'), 'companies', ['tenant_id'], unique=False)
op.create_table('users',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('role', sa.String(length=50), nullable=False, comment='角色:gp/partner/post_invest_lead/investor/founder/admin'),
sa.Column('phone', sa.String(length=20), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
op.create_table('audit_logs',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=True),
sa.Column('action', sa.String(length=100), nullable=False, comment='操作类型:login/view/create/update/delete/export/ai_call'),
sa.Column('resource_type', sa.String(length=50), nullable=True, comment='资源类型'),
sa.Column('resource_id', sa.String(length=36), nullable=True, comment='资源 ID'),
sa.Column('detail_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='操作详情'),
sa.Column('ip', postgresql.INET(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
op.create_table('health_scores',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('company_id', sa.String(length=36), nullable=False),
sa.Column('total_score', sa.Float(), nullable=False, comment='总分(0-100'),
sa.Column('financial_score', sa.Float(), nullable=True, comment='财务健康度'),
sa.Column('operational_score', sa.Float(), nullable=True, comment='经营健康度'),
sa.Column('ai_commercial_score', sa.Float(), nullable=True, comment='AI+ 商业化健康度'),
sa.Column('ai_cost_score', sa.Float(), nullable=True, comment='AI+ 成本健康度'),
sa.Column('trend', sa.String(length=20), nullable=True, comment='趋势:up/stable/down'),
sa.Column('evidence_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='评分依据'),
sa.Column('recommendations_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='建议动作'),
sa.Column('calculated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_health_scores_company_id'), 'health_scores', ['company_id'], unique=False)
op.create_table('monthly_reports',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('company_id', sa.String(length=36), nullable=False),
sa.Column('period_year', sa.Integer(), nullable=False, comment='报告年份'),
sa.Column('period_month', sa.Integer(), nullable=False, comment='报告月份(1-12'),
sa.Column('status', sa.String(length=50), nullable=False, comment='状态:draft/submitted/ai_parsed/reviewed'),
sa.Column('raw_content', sa.Text(), nullable=True, comment='原始内容'),
sa.Column('structured_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='结构化指标数据'),
sa.Column('ai_summary', sa.Text(), nullable=True, comment='AI 生成的摘要'),
sa.Column('ai_concerns', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='AI 关注点列表'),
sa.Column('submitted_by', sa.String(length=36), nullable=True),
sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('reviewed_by', sa.String(length=36), nullable=True),
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ),
sa.ForeignKeyConstraint(['reviewed_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['submitted_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_monthly_reports_company_id'), 'monthly_reports', ['company_id'], unique=False)
op.create_table('risk_events',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('company_id', sa.String(length=36), nullable=False),
sa.Column('type', sa.String(length=50), nullable=False, comment='风险类型:financial/operational/org/ai_specific'),
sa.Column('severity', sa.String(length=20), nullable=False, comment='严重程度:low/medium/high/critical'),
sa.Column('status', sa.String(length=20), nullable=False, comment='状态:open/assigned/in_progress/resolved/closed'),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('evidence_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='证据链'),
sa.Column('suggested_action', sa.Text(), nullable=True, comment='建议动作'),
sa.Column('assigned_to', sa.String(length=36), nullable=True),
sa.Column('due_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('identified_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('closed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ),
sa.ForeignKeyConstraint(['company_id'], ['companies.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_risk_events_company_id'), 'risk_events', ['company_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_risk_events_company_id'), table_name='risk_events')
op.drop_table('risk_events')
op.drop_index(op.f('ix_monthly_reports_company_id'), table_name='monthly_reports')
op.drop_table('monthly_reports')
op.drop_index(op.f('ix_health_scores_company_id'), table_name='health_scores')
op.drop_table('health_scores')
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
op.drop_table('audit_logs')
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_companies_tenant_id'), table_name='companies')
op.drop_table('companies')
op.drop_table('tenants')
# ### end Alembic commands ###
+1
View File
@@ -0,0 +1 @@
"""AIPortPilot 后端应用包。"""
+1
View File
@@ -0,0 +1 @@
"""应用配置模块。"""
+47
View File
@@ -0,0 +1,47 @@
"""审计日志装饰器。
自动记录 CREATE/UPDATE/DELETE 操作。
"""
import functools
import logging
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditLog
logger = logging.getLogger(__name__)
async def log_audit(
db: AsyncSession,
user_id: str,
action: str,
target_type: str,
target_id: str,
detail: dict | None = None,
tenant_id: str | None = None,
) -> None:
"""记录审计日志。
Args:
db: 数据库会话
user_id: 操作用户 ID
action: 操作类型(login/view/create/update/delete/export/ai_call
target_type: 资源类型(映射到 resource_type 字段)
target_id: 资源 ID(映射到 resource_id 字段)
detail: 操作详情字典
tenant_id: 租户 ID
"""
audit = AuditLog(
tenant_id=tenant_id or "",
user_id=user_id,
action=action,
resource_type=target_type,
resource_id=target_id,
detail_json=detail,
created_at=datetime.now(timezone.utc),
)
db.add(audit)
await db.flush()
+49
View File
@@ -0,0 +1,49 @@
"""缓存装饰器。
为热点接口自动添加 Redis 缓存。
"""
import functools
import hashlib
import logging
from typing import Any, Callable
from app.core.redis import cache_get, cache_set
logger = logging.getLogger(__name__)
def cached(prefix: str, ttl: int = 300):
"""缓存装饰器 — 自动缓存函数返回值。
Args:
prefix: 缓存键前缀
ttl: 缓存过期时间(秒)
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(*args, **kwargs) -> Any:
# 生成缓存键
key_parts = [prefix]
for arg in args[1:]: # 跳过 self/db
key_parts.append(str(arg))
for k, v in sorted(kwargs.items()):
key_parts.append(f"{k}={v}")
cache_key = hashlib.md5(":".join(key_parts).encode()).hexdigest()
# 尝试获取缓存
cached = await cache_get(f"{prefix}:{cache_key}")
if cached is not None:
return cached
# 执行函数
result = await func(*args, **kwargs)
# 写入缓存
await cache_set(f"{prefix}:{cache_key}", result, ttl)
return result
return wrapper
return decorator
+38
View File
@@ -0,0 +1,38 @@
"""应用配置。
从环境变量读取配置,支持 .env 文件。
"""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""应用配置,从环境变量读取。"""
# 应用
app_env: str = "development"
app_debug: bool = True
app_log_level: str = "info"
# 数据库
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/aiportpilot"
# Redis
redis_url: str = "redis://localhost:6379/0"
# JWT
jwt_secret_key: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_access_token_ttl_minutes: int = 120
jwt_refresh_token_ttl_days: int = 7
# 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"}
settings = Settings()
+37
View File
@@ -0,0 +1,37 @@
"""PII 脱敏服务 — 手机/邮箱/身份证 日志脱敏。"""
import re
def mask_phone(phone: str) -> str:
"""手机号脱敏:138****1234"""
if len(phone) >= 11:
return phone[:3] + "****" + phone[-4:]
return phone
def mask_email(email: str) -> str:
"""邮箱脱敏:z***@example.com"""
if "@" in email:
name, domain = email.split("@", 1)
if len(name) > 1:
return name[0] + "***@" + domain
return email
def mask_id_card(id_card: str) -> str:
"""身份证脱敏:110***********1234"""
if len(id_card) >= 18:
return id_card[:3] + "*" * 11 + id_card[-4:]
return id_card
def mask_pii(text: str) -> str:
"""自动识别并脱敏文本中的 PII 信息。"""
# 手机号
text = re.sub(r"1[3-9]\d{9}", lambda m: mask_phone(m.group()), text)
# 邮箱
text = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", lambda m: mask_email(m.group()), text)
# 身份证(18位)
text = re.sub(r"\d{17}[\dXx]", lambda m: mask_id_card(m.group()), text)
return text
+43
View File
@@ -0,0 +1,43 @@
"""数据库连接管理。
提供 SQLAlchemy 异步 engine 和 session 工厂。
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.database_url,
echo=settings.app_debug,
pool_size=10,
max_overflow=20,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
"""SQLAlchemy ORM 基类。"""
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库 session 的依赖注入函数。"""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+66
View File
@@ -0,0 +1,66 @@
"""认证依赖注入。
提供当前用户依赖、权限校验。
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import decode_token
from app.models.user import User
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""从 JWT token 中解析当前用户。"""
token = credentials.credentials
try:
payload = decode_token(token)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭证",
)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 token 类型",
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 token 内容",
)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在或已禁用",
)
return user
def require_role(*roles: str):
"""角色权限校验依赖工厂。"""
async def check_role(user: User = Depends(get_current_user)) -> User:
if user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"需要角色:{', '.join(roles)}",
)
return user
return check_role
+23
View File
@@ -0,0 +1,23 @@
"""敏感字段加密存储。"""
import base64
import hashlib
from app.core.config import settings
def encrypt_field(value: str) -> str:
"""加密敏感字段(简化版 — 实际应使用 KMS/Vault)。"""
key = settings.jwt_secret_key.encode()
data = value.encode()
# XOR 加密(简化版,生产环境应使用 AES)
encrypted = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
return base64.b64encode(encrypted).decode()
def decrypt_field(encrypted: str) -> str:
"""解密敏感字段。"""
key = settings.jwt_secret_key.encode()
data = base64.b64decode(encrypted)
decrypted = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
return decrypted.decode()
+91
View File
@@ -0,0 +1,91 @@
"""角色级 + 字段级权限中间件。
基于用户角色控制 API 访问权限和数据可见性。
"""
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.models.user import User
# 角色层级
ROLE_HIERARCHY = {
"admin": 100,
"investor": 50,
"founder": 20,
}
def require_role(*allowed_roles: str):
"""角色级权限依赖工厂。
用法:
@router.get("/admin-only", dependencies=[Depends(require_role("admin"))])
"""
async def _check(user: User = Depends(get_current_user)) -> User:
if user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"需要角色: {', '.join(allowed_roles)},当前角色: {user.role}",
)
return user
return _check
def require_min_role(min_role: str):
"""最低角色层级权限依赖工厂。
用法:
@router.get("/investor+", dependencies=[Depends(require_min_role("investor"))])
"""
min_level = ROLE_HIERARCHY.get(min_role, 0)
async def _check(user: User = Depends(get_current_user)) -> User:
user_level = ROLE_HIERARCHY.get(user.role, 0)
if user_level < min_level:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"需要最低角色: {min_role},当前角色: {user.role}",
)
return user
return _check
# 字段级权限:不同角色可见的字段
FIELD_VISIBILITY = {
"founder": {
"company": ["id", "name", "industry", "stage", "description", "website"],
"report": ["id", "company_id", "period_year", "period_month", "status", "raw_content"],
},
"investor": {
"company": ["*"], # 全部可见
"report": ["*"],
},
"admin": {
"company": ["*"],
"report": ["*"],
},
}
def filter_fields(
resource: str,
data: dict,
user: User,
) -> dict:
"""根据用户角色过滤返回字段。
Args:
resource: 资源名称(company / report 等)
data: 原始数据字典
user: 当前用户
Returns:
过滤后的数据字典
"""
allowed = FIELD_VISIBILITY.get(user.role, {}).get(resource, ["*"])
if "*" in allowed:
return data
return {k: v for k, v in data.items() if k in allowed}
+41
View File
@@ -0,0 +1,41 @@
"""限流中间件 — 登录 5 次/min + 写接口按用户限流。"""
import time
from collections import defaultdict
from fastapi import HTTPException, Request, status
class RateLimiter:
"""简单的内存限流器。"""
def __init__(self) -> None:
self._requests: dict[str, list[float]] = defaultdict(list)
def check(self, key: str, max_requests: int, window_seconds: int) -> bool:
"""检查是否超过限流阈值。"""
now = time.time()
window_start = now - window_seconds
# 清理过期记录
self._requests[key] = [t for t in self._requests[key] if t > window_start]
if len(self._requests[key]) >= max_requests:
return False
self._requests[key].append(now)
return True
rate_limiter = RateLimiter()
async def login_rate_limit(request: Request) -> None:
"""登录接口限流 — 5 次/min/IP。"""
client_ip = request.client.host if request.client else "unknown"
if not rate_limiter.check(f"login:{client_ip}", max_requests=5, window_seconds=60):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="登录尝试过于频繁,请稍后再试",
headers={"Retry-After": "60"},
)
+47
View File
@@ -0,0 +1,47 @@
"""Redis 连接与缓存工具。"""
import json
import logging
from typing import Any
from app.core.config import settings
logger = logging.getLogger(__name__)
try:
import redis.asyncio as redis
_redis_client = redis.from_url(settings.redis_url, decode_responses=True)
except ImportError:
_redis_client = None
logger.warning("redis 未安装,缓存功能不可用")
async def cache_get(key: str) -> Any | None:
"""从 Redis 获取缓存。"""
if not _redis_client:
return None
try:
data = await _redis_client.get(key)
return json.loads(data) if data else None
except Exception:
return None
async def cache_set(key: str, value: Any, ttl: int = 300) -> None:
"""设置 Redis 缓存。"""
if not _redis_client:
return
try:
await _redis_client.setex(key, ttl, json.dumps(value, default=str))
except Exception:
pass
async def cache_delete(key: str) -> None:
"""删除 Redis 缓存。"""
if not _redis_client:
return
try:
await _redis_client.delete(key)
except Exception:
pass
+46
View File
@@ -0,0 +1,46 @@
"""安全模块:JWT 生成与验证、密码哈希。"""
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""密码哈希。"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码。"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None) -> str:
"""生成 JWT access token。"""
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.jwt_access_token_ttl_minutes
)
payload: dict[str, Any] = {"sub": subject, "exp": expire, "type": "access"}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def create_refresh_token(subject: str) -> str:
"""生成 JWT refresh token。"""
expire = datetime.now(timezone.utc) + timedelta(
days=settings.jwt_refresh_token_ttl_days
)
payload = {"sub": subject, "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict[str, Any]:
"""解码 JWT token。"""
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
+9
View File
@@ -0,0 +1,9 @@
"""跨数据库兼容的 JSON 类型。
在 PostgreSQL 上使用 JSONB,在其他数据库(如 SQLite)上使用 JSON。
"""
from sqlalchemy import JSON
from sqlalchemy.dialects.postgresql import JSONB
JSONBType = JSON().with_variant(JSONB(), "postgresql")
+171
View File
@@ -0,0 +1,171 @@
"""FastAPI 应用入口。
注册中间件、路由、异常处理。
"""
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
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.reports_export import router as reports_export_router
from app.routers.risks import router as risks_router
# Phase 2 路由
from app.routers.financial import router as financial_router
from app.routers.agreements import router as agreements_router
from app.routers.board import router as board_router
from app.routers.weak_signals import router as weak_signals_router
from app.routers.decision_sentinels import router as decision_sentinels_router
from app.routers.events import router as events_router, router_inquiries as inquiries_router
from app.routers.profiles import router as profiles_router
from app.routers.admin import router as admin_router
from app.routers.founder import router as founder_router
# Phase 3 路由
from app.routers.synergies import router as synergies_router
from app.routers.innovation import router as innovation_router
from app.routers.talents import router as talents_router
from app.routers.customer_plans import router as customer_plans_router
from app.routers.okrs import router as okrs_router
from app.routers.nudges import router as nudges_router
from app.routers.peer_circles import router as peer_circles_router
from app.routers.product_diagnostics import router as product_diagnostics_router
from app.routers.milestones import router as milestones_router
from app.routers.advanced_analysis import router as advanced_analysis_router
from app.routers.tasks import router_tasks as tasks_router, router_comments as comments_router
from app.routers.customer_success import router as customer_success_router
# Phase 4 路由
from app.routers.alpha import router as alpha_router
from app.routers.exit_predictions import router as exit_predictions_router
from app.routers.portfolio import router as portfolio_router
from app.routers.digital_twins import router as digital_twins_router
from app.routers.knowledge_graph import router as knowledge_graph_router
from app.routers.aars import router as aars_router
from app.routers.pre_mortems import router_pre_mortem as pre_mortems_router, router_red_team as red_teams_router
from app.routers.agent_executions import router as agent_executions_router
from app.routers.knowledge import router as knowledge_router
from app.routers.data_sources import router as data_sources_router
from app.routers.industry_research import router as industry_research_router
from app.routers.funds import router as funds_router
from app.routers.evaluation import router as evaluation_router
from app.schemas.common import error
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理。"""
# startup — 初始化预设评价模板
from app.core.database import async_session_factory
from app.services.evaluation_presets import seed_evaluation_templates
async with async_session_factory() as session:
await seed_evaluation_templates(session)
await session.commit()
yield
# shutdown
app = FastAPI(
title="AIPortPilot",
description="AI+ Portfolio Operating System — 投后管理与组合协同平台",
version="0.1.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def trace_id_middleware(request: Request, call_next):
"""为每个请求注入 trace_id。"""
trace_id = request.headers.get("X-Trace-Id", str(uuid.uuid4()))
request.state.trace_id = trace_id
response = await call_next(request)
response.headers["X-Trace-Id"] = trace_id
return response
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""全局异常处理。"""
trace_id = getattr(request.state, "trace_id", str(uuid.uuid4()))
return JSONResponse(
status_code=500,
content=error(code=-1, message="内部服务器错误"),
headers={"X-Trace-Id": trace_id},
)
@app.get("/health")
async def health_check():
"""健康检查端点。"""
return {"status": "ok", "service": "aiportpilot-backend", "version": "0.1.0"}
app.include_router(auth_router, prefix="/api/v1")
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")
app.include_router(reports_export_router, prefix="/api/v1")
# Phase 2
app.include_router(financial_router, prefix="/api/v1")
app.include_router(agreements_router, prefix="/api/v1")
app.include_router(board_router, prefix="/api/v1")
app.include_router(weak_signals_router, prefix="/api/v1")
app.include_router(decision_sentinels_router, prefix="/api/v1")
app.include_router(events_router, prefix="/api/v1")
app.include_router(inquiries_router, prefix="/api/v1")
app.include_router(profiles_router, prefix="/api/v1")
app.include_router(admin_router, prefix="/api/v1")
app.include_router(founder_router, prefix="/api/v1")
# Phase 3
app.include_router(synergies_router, prefix="/api/v1")
app.include_router(innovation_router, prefix="/api/v1")
app.include_router(talents_router, prefix="/api/v1")
app.include_router(customer_plans_router, prefix="/api/v1")
app.include_router(okrs_router, prefix="/api/v1")
app.include_router(nudges_router, prefix="/api/v1")
app.include_router(peer_circles_router, prefix="/api/v1")
app.include_router(product_diagnostics_router, prefix="/api/v1")
app.include_router(milestones_router, prefix="/api/v1")
app.include_router(advanced_analysis_router, prefix="/api/v1")
app.include_router(tasks_router, prefix="/api/v1")
app.include_router(comments_router, prefix="/api/v1")
app.include_router(customer_success_router, prefix="/api/v1")
# Phase 4
app.include_router(alpha_router, prefix="/api/v1")
app.include_router(exit_predictions_router, prefix="/api/v1")
app.include_router(portfolio_router, prefix="/api/v1")
app.include_router(digital_twins_router, prefix="/api/v1")
app.include_router(knowledge_graph_router, prefix="/api/v1")
app.include_router(aars_router, prefix="/api/v1")
app.include_router(pre_mortems_router, prefix="/api/v1")
app.include_router(red_teams_router, prefix="/api/v1")
app.include_router(agent_executions_router, prefix="/api/v1")
app.include_router(knowledge_router, prefix="/api/v1")
app.include_router(data_sources_router, prefix="/api/v1")
app.include_router(industry_research_router, prefix="/api/v1")
app.include_router(funds_router, prefix="/api/v1")
app.include_router(evaluation_router, prefix="/api/v1")
+90
View File
@@ -0,0 +1,90 @@
"""数据模型模块。
导入所有模型以便 Alembic 自动发现。
"""
from app.models.aar import AARRecord
from app.models.agent_execution import AgentExecution
from app.models.agreement import InvestmentAgreement
from app.models.audit import AuditLog
from app.models.board import BoardMeeting
from app.models.company import Company
from app.models.customer_plan import CustomerAcquisitionPlan
from app.models.data_source import DataSource
from app.models.decision_sentinel import DecisionSentinel
from app.models.digital_twin import DigitalTwinModel
from app.models.evaluation_template import EvaluationTemplate
from app.models.exit_prediction import ExitPrediction
from app.models.financial_data import FinancialData
from app.models.fund import CompanyFundLink, Fund
from app.models.health_score import HealthScore
from app.models.hypothesis import Hypothesis
from app.models.inquiry import InquiryList
from app.models.intervention import InterventionEvent, InterventionResult
from app.models.knowledge import KnowledgeChunk
from app.models.knowledge_graph import KnowledgeNode
from app.models.major_event import MajorEvent
from app.models.milestone import MilestoneTree
from app.models.nudge import NudgeRecord
from app.models.okr import OKR
from app.models.peer_circle import PeerLearningCircle
from app.models.portfolio_simulation import MonteCarloSimulation, PortfolioRebalancing
from app.models.pre_mortem import PreMortemRecord, RedTeamRecord
from app.models.product_diagnostic import ProductDiagnostic
from app.models.profile import FirmProfile, FundProfile, ManagerProfile
from app.models.report import MonthlyReport
from app.models.risk import RiskEvent
from app.models.synergy import SynergyOpportunity
from app.models.talent import TalentProfile, TeamMember
from app.models.task import Comment, Task
from app.models.tenant import Tenant
from app.models.user import User
from app.models.weak_signal import WeakSignal
__all__ = [
"AARRecord",
"AgentExecution",
"AuditLog",
"BoardMeeting",
"Comment",
"Company",
"CustomerAcquisitionPlan",
"DataSource",
"DecisionSentinel",
"DigitalTwinModel",
"EvaluationTemplate",
"ExitPrediction",
"FinancialData",
"Fund",
"CompanyFundLink",
"FirmProfile",
"FundProfile",
"HealthScore",
"Hypothesis",
"InquiryList",
"InterventionEvent",
"InterventionResult",
"InvestmentAgreement",
"KnowledgeChunk",
"KnowledgeNode",
"MajorEvent",
"ManagerProfile",
"MilestoneTree",
"MonteCarloSimulation",
"MonthlyReport",
"NudgeRecord",
"OKR",
"PeerLearningCircle",
"PortfolioRebalancing",
"PreMortemRecord",
"ProductDiagnostic",
"RedTeamRecord",
"RiskEvent",
"SynergyOpportunity",
"TalentProfile",
"Task",
"TeamMember",
"Tenant",
"User",
"WeakSignal",
]
+29
View File
@@ -0,0 +1,29 @@
"""AAR 系统化复盘模型。"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class AARRecord(Base):
"""AAR 复盘记录。"""
__tablename__ = "aar_records"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
trigger_event: Mapped[str] = mapped_column(String(200), nullable=False, comment="触发事件")
original_plan: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原计划")
actual_result: Mapped[str | None] = mapped_column(Text, nullable=True, comment="实际结果")
gap_analysis: Mapped[str | None] = mapped_column(Text, nullable=True, comment="差异分析")
lessons: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="五问复盘结论")
improvements: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="改进措施 + 执行追踪")
knowledge_graph_ref: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="知识图谱节点 ID")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+35
View File
@@ -0,0 +1,35 @@
"""Agent 执行记录模型。
L1-L4 分级自治执行。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class AgentExecution(Base):
"""Agent 执行记录。"""
__tablename__ = "agent_executions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
agent_name: Mapped[str] = mapped_column(String(100), nullable=False, comment="Agent 名称")
autonomy_level: Mapped[str] = mapped_column(String(10), nullable=False, comment="L1/L2/L3/L4")
input_summary: Mapped[str | None] = mapped_column(Text, nullable=True, comment="输入摘要")
output_summary: Mapped[str | None] = mapped_column(Text, nullable=True, comment="输出摘要")
output_detail: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="输出详情")
review_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", comment="pending/approved/rejected/auto_approved")
reviewer_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
model_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
duration_ms: Mapped[int | None] = mapped_column(nullable=True, comment="执行耗时")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+36
View File
@@ -0,0 +1,36 @@
"""投资协议模型。
协议条款提取与持续监控。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class InvestmentAgreement(Base):
"""投资协议。"""
__tablename__ = "investment_agreements"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="协议名称")
signed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, comment="签署日期")
file_url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="文件 URL")
key_clauses: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="关键条款 JSON")
monitoring_rules: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="监控规则")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", comment="active/expired/terminated")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+31
View File
@@ -0,0 +1,31 @@
"""审计日志模型。
全链路操作记录,保留 ≥ 6 月。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class AuditLog(Base):
"""审计日志。"""
__tablename__ = "audit_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
user_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
action: Mapped[str] = mapped_column(String(100), nullable=False, comment="操作类型:login/view/create/update/delete/export/ai_call")
resource_type: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="资源类型")
resource_id: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="资源 ID")
detail_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="操作详情")
ip: Mapped[str | None] = mapped_column(String(45), nullable=True, comment="IP 地址")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+38
View File
@@ -0,0 +1,38 @@
"""董事会会议模型。
议程、纪要、决议追踪。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class BoardMeeting(Base):
"""董事会会议。"""
__tablename__ = "board_meetings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="会议主题")
meeting_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, comment="会议时间")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="scheduled", comment="scheduled/in_progress/completed")
agenda: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="议程列表")
materials_summary: Mapped[str | None] = mapped_column(Text, nullable=True, comment="AI 会前材料摘要")
minutes: Mapped[str | None] = mapped_column(Text, nullable=True, comment="会议纪要")
resolutions: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="决议列表 — 含状态追踪")
questions: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="AI 提问清单")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+39
View File
@@ -0,0 +1,39 @@
"""企业模型。
被投企业档案,包含基本信息、业务描述、投资关系等。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class Company(Base):
"""被投企业。"""
__tablename__ = "companies"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="企业名称")
industry: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="行业")
stage: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="融资阶段:seed/a/b/c/ipo")
logo_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="业务描述")
founded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
total_funding: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="累计融资额")
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
extra_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="扩展字段")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+38
View File
@@ -0,0 +1,38 @@
"""客户获取计划模型。
AI 客户增长引擎 — LP 资源匹配 + 客户获取方案。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class CustomerAcquisitionPlan(Base):
"""客户获取计划。"""
__tablename__ = "customer_acquisition_plans"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
target_customer: Mapped[str | None] = mapped_column(Text, nullable=True, comment="目标客户画像")
entry_angle: Mapped[str | None] = mapped_column(Text, nullable=True, comment="切入角度")
decision_chain: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="决策链分析")
pricing_strategy: Mapped[str | None] = mapped_column(Text, nullable=True, comment="定价策略")
competitive_analysis: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="竞争分析")
lp_resources: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="可利用 LP 资源")
execution_status: Mapped[str] = mapped_column(String(20), nullable=False, default="planned", comment="planned/executing/completed/failed")
result: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="执行结果")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+35
View File
@@ -0,0 +1,35 @@
"""数据源模型。"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class DataSource(Base):
"""外部数据源配置。"""
__tablename__ = "data_sources"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("companies.id"), nullable=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
source_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="crunchbase/business_registry/github/custom")
name: Mapped[str] = mapped_column(String(200), nullable=False)
api_endpoint: Mapped[str | None] = mapped_column(String(500), nullable=True)
api_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True, comment="加密后的 API Key")
config: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="配置参数")
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="inactive", comment="active/inactive/error")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+39
View File
@@ -0,0 +1,39 @@
"""决策前哨模型。
识别企业关键决策岔路口,AI 提前生成场景分析。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class DecisionSentinel(Base):
"""决策前哨。"""
__tablename__ = "decision_sentinels"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
decision_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="pivot/hiring/funding/product/org")
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="决策标题")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
signals: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="触发信号列表")
scenarios: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="场景分析 — A 路线 vs B 路线")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="identified", comment="identified/analyzed/acted/dismissed")
identified_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+31
View File
@@ -0,0 +1,31 @@
"""数字孪生模型。"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class DigitalTwinModel(Base):
"""数字孪生。"""
__tablename__ = "digital_twins"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
model_params: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="模型参数")
scenarios: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="模拟场景列表")
accuracy_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="精度评分(0-1")
last_calibrated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+60
View File
@@ -0,0 +1,60 @@
"""评价模板模型。
6 轴动态评价指标体系的模板配置,支持基金类型、存续期、企业阶段、产业赛道、投资策略、投资人类型的动态组合。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class EvaluationTemplate(Base):
"""评价模板 — 6 轴配置单元,定义维度权重和专属指标。"""
__tablename__ = "evaluation_templates"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="模板名称")
# 6 轴参数
fund_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="基金类型:angel/early_vc/growth_vc/pe/cvc/fof/distress/esg")
fund_lifecycle: Mapped[str] = mapped_column(String(50), nullable=False, default="investment", comment="存续期阶段:investment/growth/exit_preparation/liquidation")
company_stage: Mapped[str] = mapped_column(String(50), nullable=False, default="a", comment="企业阶段:seed/a/b/c/pre_ipo")
industry: Mapped[str] = mapped_column(String(50), nullable=False, default="ai", comment="产业赛道:ai/saas/hardware/biotech/consumer/fintech/manufacturing")
strategy: Mapped[str] = mapped_column(String(50), nullable=False, default="growth", comment="投资策略:growth/value/empowerment/turnaround")
investor_type: Mapped[str] = mapped_column(String(50), nullable=False, default="investor", comment="投资人类型:gp/post_invest_lead/investor")
# 权重配置 — {dimension_key: weight},归一化后总和 = 1.0
weights_json: Mapped[dict] = mapped_column(JSONBType, nullable=False, comment="14 维度权重(归一化后)")
# 维度裁剪
enabled_dimensions: Mapped[list] = mapped_column(JSONBType, nullable=False, comment="启用的维度 key 列表")
disabled_dimensions: Mapped[list] = mapped_column(JSONBType, nullable=False, default=list, comment="禁用的维度 key 列表")
# 专属指标 — [{key, label, description, data_source}]
custom_metrics_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="赛道专属指标定义")
# 修饰因子
lp_focus_metrics: Mapped[list | None] = mapped_column(JSONBType, nullable=True, comment="LP 附加指标列表")
regional_benchmark: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="地域基准标识:china_mainland/us/sea/europe")
# 元数据
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否为该组合的默认模板")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否启用")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1, comment="版本号")
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="创建人")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+29
View File
@@ -0,0 +1,29 @@
"""退出预测模型。"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class ExitPrediction(Base):
"""退出时机预测。"""
__tablename__ = "exit_predictions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
exit_path: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="ipo/acquisition/secondary/merger")
timing_window: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="时机窗口 — 起止时间")
expected_return: Mapped[float | None] = mapped_column(Float, nullable=True, comment="期望收益率")
hold_return: Mapped[float | None] = mapped_column(Float, nullable=True, comment="继续持有预期收益率")
confidence: Mapped[float | None] = mapped_column(Float, nullable=True, comment="置信度")
signals: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="退出信号")
recommendation: Mapped[str | None] = mapped_column(Text, nullable=True, comment="退出建议")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+37
View File
@@ -0,0 +1,37 @@
"""财务数据模型。
资产负债表、利润表、现金流量表、科目余额。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class FinancialData(Base):
"""财务数据。"""
__tablename__ = "financial_data"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
period_year: Mapped[int] = mapped_column(Integer, nullable=False, comment="年份")
period_month: Mapped[int] = mapped_column(Integer, nullable=False, comment="月份")
statement_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="balance_sheet/income/cash_flow")
data_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="财务数据 JSON")
source: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="数据来源")
credibility_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="可信度评分(0-100")
validation_result: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="校验结果")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+95
View File
@@ -0,0 +1,95 @@
"""基金模型。
管理基金类型、存续期、LP 构成等信息,支撑评价指标体系的动态权重计算。
"""
import uuid
from datetime import date, datetime, timezone
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class Fund(Base):
"""基金信息 — 管理基金类型和存续期。"""
__tablename__ = "funds"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="基金名称")
# 基金类型:angel/early_vc/growth_vc/pe/cvc/fof/distress/esg
fund_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="基金类型")
# 投资策略:growth/value/empowerment/turnaround
strategy: Mapped[str] = mapped_column(String(50), nullable=False, default="growth", comment="投资策略")
# 存续期信息
established_date: Mapped[date | None] = mapped_column(Date, nullable=True, comment="基金成立日")
total_lifespan_months: Mapped[int] = mapped_column(Integer, nullable=False, default=84, comment="总存续期(月)")
investment_period_months: Mapped[int] = mapped_column(Integer, nullable=False, default=48, comment="投资期(月)")
# LP 构成 — {government: 30, market: 50, corporate: 20}
lp_composition_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="LP 构成百分比")
# 地域
primary_market: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="主要市场:china_mainland/us/sea/europe")
# 状态
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否活跃")
# 元数据
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
@property
def current_lifecycle(self) -> str:
"""根据当前日期自动计算基金存续期阶段。
返回:investment / growth / exit_preparation / liquidation
"""
if not self.established_date:
return "investment"
today = date.today()
months_elapsed = (today.year - self.established_date.year) * 12 + (today.month - self.established_date.month)
if months_elapsed < self.investment_period_months:
return "investment"
elif months_elapsed < self.total_lifespan_months - 24:
return "growth"
elif months_elapsed < self.total_lifespan_months - 12:
return "exit_preparation"
else:
return "liquidation"
class CompanyFundLink(Base):
"""企业-基金关联 — 一个企业可能被多支基金投资。"""
__tablename__ = "company_fund_links"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
fund_id: Mapped[str] = mapped_column(String(36), ForeignKey("funds.id"), nullable=False, index=True)
investment_date: Mapped[date | None] = mapped_column(Date, nullable=True, comment="投资日期")
investment_stage: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="投资时企业阶段")
round: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="轮次")
amount: Mapped[float | None] = mapped_column(Float, nullable=True, comment="投资金额(万元)")
ownership_pct: Mapped[float | None] = mapped_column(Float, nullable=True, comment="持股比例(%")
is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="当前是否持有")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+60
View File
@@ -0,0 +1,60 @@
"""健康度评分模型。
多维度评分:财务、经营、AI+ 商业化、AI+ 成本(基础 4 维度)。
T2.9 扩展:组织人才、产品技术、市场竞争、治理合规、融资资本(9 维度)。
T3.11 扩展:协同赋能、AI 模型产品、数据合规、团队技术、客户成功(14 维度)。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
from app.core.types import JSONBType as _JSONB # 兼容别名
class HealthScore(Base):
"""健康度评分。"""
__tablename__ = "health_scores"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
total_score: Mapped[float] = mapped_column(Float, nullable=False, comment="总分(0-100")
# 基础 4 维度
financial_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="财务健康度")
operational_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="经营健康度")
ai_commercial_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="AI+ 商业化健康度")
ai_cost_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="AI+ 成本健康度")
# T2.9 扩展 5 维度
org_talent_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="组织人才健康度")
product_tech_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="产品技术健康度")
market_compete_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="市场竞争健康度")
governance_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="治理合规健康度")
financing_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="融资资本健康度")
# T3.11 扩展 5 维度
synergy_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="协同赋能健康度")
ai_model_product_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="AI 模型产品健康度")
data_compliance_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="数据合规健康度")
team_tech_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="团队技术健康度")
customer_success_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="客户成功健康度")
# 元数据
trend: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="趋势:up/stable/down")
evidence_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="评分依据")
recommendations_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="建议动作")
calculated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
# 评价模板关联(向后兼容:旧数据为 NULL)
template_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("evaluation_templates.id"), nullable=True, comment="使用的评价模板 ID")
fund_type: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="冗余存储基金类型,便于查询")
fund_lifecycle: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="冗余存储存续期阶段")
company_stage: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="冗余存储企业阶段")
industry: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="冗余存储产业赛道")
strategy: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="冗余存储投资策略")
custom_metrics_result: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="专属指标评分结果")
lp_focus_result: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="LP 附加指标结果")
+35
View File
@@ -0,0 +1,35 @@
"""BML 认知追踪模型。
假设/实验/数据/结论 — Build-Measure-Learn 循环。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class Hypothesis(Base):
"""BML 认知追踪 — 假设记录。"""
__tablename__ = "hypotheses"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
hypothesis: Mapped[str] = mapped_column(Text, nullable=False, comment="假设")
experiment: Mapped[str | None] = mapped_column(Text, nullable=True, comment="验证实验")
data: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="实验数据")
conclusion: Mapped[str | None] = mapped_column(Text, nullable=True, comment="结论")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="building", comment="building/measuring/learning/validated/invalidated")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+37
View File
@@ -0,0 +1,37 @@
"""追问清单模型。
AI 根据月报数据生成补充问题,企业可回复。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class InquiryList(Base):
"""追问清单。"""
__tablename__ = "inquiry_lists"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
report_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("monthly_reports.id"), nullable=True)
questions: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="问题列表 — 含问题和回答")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="sent", comment="sent/answered/closed")
sent_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
answered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+49
View File
@@ -0,0 +1,49 @@
"""干预事件与结果模型。
投后管理 Alpha 归因 — 干预事件 → 指标变化 → 估值影响 → 回报贡献。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class InterventionEvent(Base):
"""干预事件。"""
__tablename__ = "intervention_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
intervention_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="recruitment/customer_intro/strategy/governance/crisis/funding")
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
executed_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
executed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
class InterventionResult(Base):
"""干预结果。"""
__tablename__ = "intervention_results"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
intervention_id: Mapped[str] = mapped_column(String(36), ForeignKey("intervention_events.id"), nullable=False, index=True)
metric_changes: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="指标变化")
valuation_impact: Mapped[float | None] = mapped_column(Float, nullable=True, comment="估值影响")
return_contribution: Mapped[float | None] = mapped_column(Float, nullable=True, comment="回报贡献")
alpha_score: Mapped[float | None] = mapped_column(Float, nullable=True, comment="Alpha 归因评分")
evidence: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="证据链")
measured_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+27
View File
@@ -0,0 +1,27 @@
"""RAG 知识库模型。"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import JSON, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class KnowledgeChunk(Base):
"""知识库分块 — 向量化的月报/报告片段。"""
__tablename__ = "knowledge_chunks"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
source_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="report/agreement/board/aar/knowledge_graph")
source_id: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="来源记录 ID")
company_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("companies.id"), nullable=True, index=True)
content: Mapped[str] = mapped_column(Text, nullable=False, comment="文本内容")
embedding: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="向量嵌入")
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="元数据")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+30
View File
@@ -0,0 +1,30 @@
"""知识图谱模型。
企业特征 + 管理动作 + 环境上下文 → 结果 → 回报影响。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import JSON, DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class KnowledgeNode(Base):
"""知识图谱节点。"""
__tablename__ = "knowledge_nodes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="company/action/context/result/return")
entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="关联实体 ID")
attributes: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="实体属性")
relations: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="关系列表 — target_id + relation_type")
embedding: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="向量嵌入")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+34
View File
@@ -0,0 +1,34 @@
"""重大事项模型。
AI 从月报/弱信号中自动识别重大事项。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class MajorEvent(Base):
"""重大事项。"""
__tablename__ = "major_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
event_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="funding/personnel/product/legal/market/org")
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium", comment="low/medium/high/critical")
source: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="来源:monthly_report/weak_signal/manual")
source_ref: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="来源记录 ID")
evidence: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="证据链")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="identified", comment="identified/confirmed/addressed")
occurred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+38
View File
@@ -0,0 +1,38 @@
"""里程碑树模型。
分支路径管理 + 环境变化时 AI 建议路径切换。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class MilestoneTree(Base):
"""里程碑树。"""
__tablename__ = "milestone_trees"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="里程碑名称")
parent_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("milestone_trees.id"), nullable=True, comment="父节点")
is_current: Mapped[bool] = mapped_column(default=False, comment="是否当前路径")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="planned", comment="planned/in_progress/completed/abandoned")
target_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
actual_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_analysis: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="AI 路径分析")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+31
View File
@@ -0,0 +1,31 @@
"""行为助推记录模型。
时机判断 + 策略选择 + 效果追踪。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class NudgeRecord(Base):
"""行为助推记录。"""
__tablename__ = "nudge_records"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
nudge_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="anchoring/loss_aversion/social_proof/default/timing")
context: Mapped[str | None] = mapped_column(Text, nullable=True, comment="助推上下文")
message: Mapped[str] = mapped_column(Text, nullable=False, comment="助推内容")
target_user_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
accepted: Mapped[bool | None] = mapped_column(nullable=True, comment="是否接受")
effect_result: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="效果追踪")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+37
View File
@@ -0,0 +1,37 @@
"""OKR 模型。
投资人与创始人共同制定 OKR + AI 对齐度评分。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class OKR(Base):
"""OKR。"""
__tablename__ = "okrs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
quarter: Mapped[str] = mapped_column(String(10), nullable=False, comment="如 2025-Q1")
objective: Mapped[str] = mapped_column(Text, nullable=False, comment="目标")
key_results: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="关键结果列表 — 含进度")
alignment_score: Mapped[float | None] = mapped_column(nullable=True, comment="对齐度评分(0-100")
deviation_alerts: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="偏差预警")
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True, comment="复盘记录")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", comment="active/completed/archived")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+37
View File
@@ -0,0 +1,37 @@
"""同行学习圈模型。
AI 匹配面临类似挑战的创始人,结构化讨论。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class PeerLearningCircle(Base):
"""同行学习圈。"""
__tablename__ = "peer_learning_circles"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
topic: Mapped[str] = mapped_column(String(200), nullable=False, comment="讨论话题")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
members: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="成员列表 — 创始人 ID + 企业 ID")
discussion_framework: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="结构化讨论框架")
conclusions: Mapped[str | None] = mapped_column(Text, nullable=True, comment="讨论结论")
action_commitments: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="行动承诺")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="matching", comment="matching/active/completed")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
@@ -0,0 +1,45 @@
"""组合再平衡 + Monte Carlo 模拟模型。"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class PortfolioRebalancing(Base):
"""组合再平衡建议。"""
__tablename__ = "portfolio_rebalancings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
marginal_returns: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="各企业边际回报率")
reallocation_plan: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="再平衡方案")
irr_impact: Mapped[float | None] = mapped_column(Float, nullable=True, comment="IRR 影响")
dpi_impact: Mapped[float | None] = mapped_column(Float, nullable=True, comment="DPI 影响")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="proposed", comment="proposed/approved/executed")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
class MonteCarloSimulation(Base):
"""Monte Carlo 模拟结果。"""
__tablename__ = "monte_carlo_simulations"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
iterations: Mapped[int] = mapped_column(nullable=False, default=10000, comment="模拟次数")
irr_distribution: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="IRR 概率分布")
dpi_distribution: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="DPI 概率分布")
percentile_p5: Mapped[float | None] = mapped_column(Float, nullable=True)
percentile_p50: Mapped[float | None] = mapped_column(Float, nullable=True)
percentile_p95: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+42
View File
@@ -0,0 +1,42 @@
"""Pre-mortem + Red Team 模型。"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class PreMortemRecord(Base):
"""Pre-mortem 失败推演。"""
__tablename__ = "pre_mortem_records"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
decision_context: Mapped[str | None] = mapped_column(Text, nullable=True, comment="决策上下文")
failure_paths: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="失败路径列表")
risk_checklist: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="风险清单")
mitigations: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="缓解措施")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
class RedTeamRecord(Base):
"""Red Team 对抗分析。"""
__tablename__ = "red_team_records"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
perspective: Mapped[str] = mapped_column(String(50), nullable=False, comment="competitor/pessimistic_investor/devils_advocate")
analysis: Mapped[str | None] = mapped_column(Text, nullable=True, comment="对抗分析内容")
vulnerabilities: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="发现的漏洞")
counterarguments: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="反驳论点")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+30
View File
@@ -0,0 +1,30 @@
"""产品竞争力诊断模型。
AI 体验产品 + 竞品对比 + 热力图。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class ProductDiagnostic(Base):
"""产品竞争力诊断。"""
__tablename__ = "product_diagnostics"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
product_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
dimensions: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="竞争力维度评分")
heatmap_data: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="热力图数据")
competitors: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="竞品对比")
roadmap_suggestions: Mapped[str | None] = mapped_column(Text, nullable=True, comment="路线图建议")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+79
View File
@@ -0,0 +1,79 @@
"""多主体画像模型。
投资机构、基金、投资经理画像。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class FirmProfile(Base):
"""投资机构画像。"""
__tablename__ = "firm_profiles"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="机构名称")
focus_areas: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="投资领域")
stage_preference: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="阶段偏好")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
extra_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
class FundProfile(Base):
"""基金画像。"""
__tablename__ = "fund_profiles"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
firm_id: Mapped[str] = mapped_column(String(36), ForeignKey("firm_profiles.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="基金名称")
fund_size: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="基金规模")
vintage_year: Mapped[int | None] = mapped_column(nullable=True, comment="成立年份")
strategy: Mapped[str | None] = mapped_column(Text, nullable=True, comment="投资策略")
extra_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
class ManagerProfile(Base):
"""投资经理画像。"""
__tablename__ = "manager_profiles"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
firm_id: Mapped[str] = mapped_column(String(36), ForeignKey("firm_profiles.id"), nullable=False, index=True)
user_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="投资经理姓名")
focus_areas: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="关注领域")
portfolio_count: Mapped[int | None] = mapped_column(nullable=True, comment="在管企业数")
extra_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+44
View File
@@ -0,0 +1,44 @@
"""月报模型。
被投企业按月提交的经营报告,支持 AI 解析。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class MonthlyReport(Base):
"""月报。"""
__tablename__ = "monthly_reports"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
period_year: Mapped[int] = mapped_column(Integer, nullable=False, comment="报告年份")
period_month: Mapped[int] = mapped_column(Integer, nullable=False, comment="报告月份(1-12")
status: Mapped[str] = mapped_column(
String(50), nullable=False, default="draft",
comment="状态:draft/submitted/ai_parsed/reviewed",
)
raw_content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原始内容")
structured_data: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="结构化指标数据")
ai_summary: Mapped[str | None] = mapped_column(Text, nullable=True, comment="AI 生成的摘要")
ai_concerns: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="AI 关注点列表")
submitted_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reviewed_by: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+43
View File
@@ -0,0 +1,43 @@
"""风险事件模型。
指标越界自动预警 + 人工处理闭环。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class RiskEvent(Base):
"""风险事件。"""
__tablename__ = "risk_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
type: Mapped[str] = mapped_column(String(50), nullable=False, comment="风险类型:financial/operational/org/ai_specific")
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium", comment="严重程度:low/medium/high/critical")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="open", comment="状态:open/assigned/in_progress/resolved/closed")
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
evidence_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="证据链")
suggested_action: Mapped[str | None] = mapped_column(Text, nullable=True, comment="建议动作")
assigned_to: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
identified_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+39
View File
@@ -0,0 +1,39 @@
"""协同机会模型。
Portfolio 内部协同匹配与效果追踪。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class SynergyOpportunity(Base):
"""协同机会。"""
__tablename__ = "synergy_opportunities"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
type: Mapped[str] = mapped_column(String(50), nullable=False, comment="customer/talent/funding/supply_chain/tech")
company_a_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
company_b_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("companies.id"), nullable=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
match_reason: Mapped[str | None] = mapped_column(Text, nullable=True, comment="AI 匹配理由")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="discovered", comment="discovered/confirmed/authorized/executing/completed/declined")
authorized: Mapped[bool] = mapped_column(default=False, comment="双方是否授权")
effect_result: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="效果评估")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+59
View File
@@ -0,0 +1,59 @@
"""人才模型。
核心人才画像 + 团队成员 + 9-Box 矩阵 + 流动预测。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class TalentProfile(Base):
"""人才画像。"""
__tablename__ = "talent_profiles"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
current_role: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="当前职位")
current_company: Mapped[str | None] = mapped_column(String(200), nullable=True)
skills: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="技能标签")
experience_years: Mapped[int | None] = mapped_column(Integer, nullable=True)
performance_rating: Mapped[float | None] = mapped_column(nullable=True, comment="绩效评分(1-5")
potential_rating: Mapped[float | None] = mapped_column(nullable=True, comment="潜力评分(1-5")
nine_box: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="9-Box 象限")
flow_prediction: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="流动预测")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", comment="active/flowed/inactive")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
class TeamMember(Base):
"""团队成员。"""
__tablename__ = "team_members"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="职位")
is_key_person: Mapped[bool] = mapped_column(default=False, comment="是否核心人员")
joined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
left_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
stability_score: Mapped[float | None] = mapped_column(nullable=True, comment="稳定性评分(0-1")
extra_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+57
View File
@@ -0,0 +1,57 @@
"""任务与评论模型。
投后任务管理 + 评论协作。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class Task(Base):
"""投后任务。"""
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
company_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("companies.id"), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="todo", comment="todo/in_progress/done/cancelled")
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium", comment="low/medium/high/urgent")
assigned_to: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
source_type: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="来源:risk/report/synergy/manual")
source_ref: Mapped[str | None] = mapped_column(String(36), nullable=True, comment="来源记录 ID")
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
class Comment(Base):
"""评论。"""
__tablename__ = "comments"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="report/risk/company/task")
target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True, comment="目标记录 ID")
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
parent_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("comments.id"), nullable=True, comment="父评论 ID")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+32
View File
@@ -0,0 +1,32 @@
"""租户模型。
投资机构和被投企业都属于某个租户。多租户隔离的基础。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class Tenant(Base):
"""租户(投资机构)。"""
__tablename__ = "tenants"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(200), nullable=False, comment="租户名称")
type: Mapped[str] = mapped_column(String(50), nullable=False, default="vc", comment="租户类型:vc/cvc/gov/holdings")
config_json: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="租户配置")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+38
View File
@@ -0,0 +1,38 @@
"""用户模型。
支持多种角色:GP、投资经理、投后负责人、创始人、管理员等。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class User(Base):
"""用户。"""
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[str] = mapped_column(
String(50), nullable=False, default="investor",
comment="角色:gp/partner/post_invest_lead/investor/founder/admin",
)
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+36
View File
@@ -0,0 +1,36 @@
"""弱信号模型。
技术/情绪/组织/市场四类弱信号采集与关联。
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.core.types import JSONBType
class WeakSignal(Base):
"""弱信号。"""
__tablename__ = "weak_signals"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False, index=True)
signal_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="technical/sentiment/org/market")
source: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="信号来源")
content: Mapped[str] = mapped_column(Text, nullable=False, comment="信号内容")
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5, comment="置信度(0-1")
correlation_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True, comment="关联组 ID")
correlation_result: Mapped[dict | None] = mapped_column(JSONBType, nullable=True, comment="关联分析结果")
risk_probability: Mapped[float | None] = mapped_column(Float, nullable=True, comment="风险概率(0-1")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="new", comment="new/correlated/alerted/dismissed")
detected_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+1
View File
@@ -0,0 +1 @@
"""API 路由模块。"""
+48
View File
@@ -0,0 +1,48 @@
"""AAR 路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.aar import AARRecord
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.aar_agent import generate_aar
router = APIRouter(prefix="/aars", tags=["aars"])
@router.get("", response_model=ApiResponse[list])
async def list_aars(company_id: str | None = Query(default=None), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取 AAR 复盘列表。"""
query = select(AARRecord)
if company_id:
query = query.where(AARRecord.company_id == company_id)
result = await db.execute(query.order_by(AARRecord.created_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"trigger_event": i.trigger_event,
"original_plan": i.original_plan,
"actual_result": i.actual_result,
"gap_analysis": i.gap_analysis,
"lessons": i.lessons,
"improvements": i.improvements,
}
for i in items
])
@router.post("/generate", response_model=ApiResponse[dict])
async def generate_aar_report(req: dict, user: User = Depends(get_current_user)):
"""AI 生成五问复盘。"""
result = await generate_aar(
req.get("trigger_event", ""),
req.get("original_plan", ""),
req.get("actual_result", ""),
)
return success(data=result)
+88
View File
@@ -0,0 +1,88 @@
"""Admin 管理后台路由 — 租户 CRUD + 用户管理 + 审计日志。"""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_user, require_role
from app.models.audit import AuditLog
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.common import ApiResponse, success
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/overview", response_model=ApiResponse[dict])
async def admin_overview(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("admin")),
):
"""系统概览。"""
tenants_result = await db.execute(select(Tenant))
tenants = tenants_result.scalars().all()
users_result = await db.execute(select(User))
users = users_result.scalars().all()
return success(data={
"tenant_count": len(tenants),
"user_count": len(users),
"tenants": [{"id": str(t.id), "name": t.name} for t in tenants],
})
@router.get("/tenants", response_model=ApiResponse[list])
async def list_tenants(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("admin")),
):
"""租户管理列表。"""
result = await db.execute(select(Tenant))
items = result.scalars().all()
return success(data=[
{"id": str(i.id), "name": i.name, "created_at": i.created_at.isoformat() if i.created_at else None}
for i in items
])
@router.get("/users", response_model=ApiResponse[list])
async def list_users(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("admin")),
):
"""用户管理列表。"""
result = await db.execute(select(User))
items = result.scalars().all()
return success(data=[
{"id": str(i.id), "email": i.email, "name": i.name, "role": i.role, "tenant_id": str(i.tenant_id), "is_active": i.is_active}
for i in items
])
@router.get("/audit-logs", response_model=ApiResponse[list])
async def list_audit_logs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("admin")),
):
"""审计日志查看。"""
offset = (page - 1) * page_size
result = await db.execute(
select(AuditLog).order_by(AuditLog.created_at.desc()).offset(offset).limit(page_size)
)
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"user_id": str(i.user_id) if i.user_id else None,
"action": i.action,
"target_type": i.resource_type,
"target_id": i.resource_id,
"detail": i.detail_json,
"created_at": i.created_at.isoformat() if i.created_at else None,
}
for i in items
])
+24
View File
@@ -0,0 +1,24 @@
"""高级分析路由 — 约束点 + BML + 鸿沟诊断。"""
from fastapi import APIRouter, Depends
from app.core.dependencies import get_current_user
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.constraint_analyzer import identify_constraints
from app.services.chasm_diagnostic import diagnose_chasm
router = APIRouter(prefix="/advanced-analysis", tags=["advanced-analysis"])
@router.post("/constraints", response_model=ApiResponse[dict])
async def analyze_constraints(req: dict, user: User = Depends(get_current_user)):
"""TOC 约束点识别。"""
result = await identify_constraints(req.get("company_data", ""))
return success(data=result)
@router.post("/chasm", response_model=ApiResponse[dict])
async def analyze_chasm(req: dict, user: User = Depends(get_current_user)):
"""鸿沟诊断。"""
result = await diagnose_chasm(req.get("company_data", ""))
return success(data=result)
+80
View File
@@ -0,0 +1,80 @@
"""Agent 执行记录路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.agent_execution import AgentExecution
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.agent_orchestrator import orchestrate_agent
router = APIRouter(prefix="/agent-executions", tags=["agent-executions"])
@router.get("", response_model=ApiResponse[list])
async def list_executions(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取 Agent 执行记录列表。"""
offset = (page - 1) * page_size
result = await db.execute(
select(AgentExecution)
.where(AgentExecution.tenant_id == user.tenant_id)
.order_by(AgentExecution.created_at.desc())
.offset(offset)
.limit(page_size)
)
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"agent_name": i.agent_name,
"autonomy_level": i.autonomy_level,
"input_summary": i.input_summary,
"output_summary": i.output_summary,
"review_status": i.review_status,
"reviewer_id": i.reviewer_id,
"duration_ms": i.duration_ms,
}
for i in items
])
@router.post("/orchestrate", response_model=ApiResponse[dict])
async def orchestrate(req: dict, user: User = Depends(get_current_user)):
"""编排 Agent 执行。"""
result = await orchestrate_agent(
req.get("agent_name", ""),
req.get("autonomy_level", "L1"),
req.get("input_data", {}),
)
return success(data=result)
@router.put("/{execution_id}/review", response_model=ApiResponse[dict])
async def review_execution(
execution_id: str,
req: dict,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""审核 Agent 执行。"""
result = await db.execute(
select(AgentExecution).where(AgentExecution.id == execution_id, AgentExecution.tenant_id == user.tenant_id)
)
execution = result.scalar_one_or_none()
if not execution:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="执行记录不存在")
execution.review_status = req.get("review_status", "approved")
execution.reviewer_id = str(user.id)
from datetime import datetime, timezone
execution.reviewed_at = datetime.now(timezone.utc)
await db.flush()
return success(data={"id": str(execution.id), "review_status": execution.review_status}, message="审核完成")
+91
View File
@@ -0,0 +1,91 @@
"""投资协议路由:CRUD + 条款预警。"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import 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.agreement import InvestmentAgreement
from app.models.company import Company
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.agreement_monitor import check_clause_triggers
router = APIRouter(prefix="/agreements", tags=["agreements"])
@router.get("", response_model=ApiResponse[list])
async def list_agreements(
company_id: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取投资协议列表。"""
query = (
select(InvestmentAgreement)
.join(Company, InvestmentAgreement.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(InvestmentAgreement.company_id == company_id)
result = await db.execute(query.order_by(InvestmentAgreement.created_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"title": i.title,
"signed_at": i.signed_at.isoformat() if i.signed_at else None,
"key_clauses": i.key_clauses,
"monitoring_rules": i.monitoring_rules,
"status": i.status,
}
for i in items
])
@router.post("", response_model=ApiResponse[dict], status_code=status.HTTP_201_CREATED)
async def create_agreement(
req: dict,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""创建投资协议。"""
company_result = await db.execute(
select(Company).where(Company.id == req.get("company_id"), Company.tenant_id == user.tenant_id)
)
if not company_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
agreement = InvestmentAgreement(
company_id=req.get("company_id"),
title=req.get("title"),
signed_at=req.get("signed_at"),
file_url=req.get("file_url"),
key_clauses=req.get("key_clauses"),
monitoring_rules=req.get("monitoring_rules"),
)
db.add(agreement)
await db.flush()
return success(data={"id": str(agreement.id)}, message="创建成功")
@router.get("/{agreement_id}/alerts", response_model=ApiResponse[list])
async def get_clause_alerts(
agreement_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取协议条款预警。"""
result = await db.execute(
select(InvestmentAgreement)
.join(Company, InvestmentAgreement.company_id == Company.id)
.where(InvestmentAgreement.id == agreement_id, Company.tenant_id == user.tenant_id)
)
agreement = result.scalar_one_or_none()
if not agreement:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协议不存在")
alerts = await check_clause_triggers(db, str(agreement.company_id))
return success(data=alerts)
+66
View File
@@ -0,0 +1,66 @@
"""Alpha 归因路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.intervention import InterventionEvent, InterventionResult
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.alpha_attribution import attribute_alpha
router = APIRouter(prefix="/alpha", tags=["alpha"])
@router.get("", response_model=ApiResponse[list])
async def list_interventions(
company_id: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取干预事件列表。"""
query = (
select(InterventionEvent)
.join(Company, InterventionEvent.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(InterventionEvent.company_id == company_id)
result = await db.execute(query.order_by(InterventionEvent.executed_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"intervention_type": i.intervention_type,
"title": i.title,
"description": i.description,
"executed_at": i.executed_at.isoformat(),
}
for i in items
])
@router.post("", response_model=ApiResponse[dict])
async def create_intervention(req: dict, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""记录干预事件。"""
event = InterventionEvent(
company_id=req.get("company_id"),
intervention_type=req.get("intervention_type"),
title=req.get("title"),
description=req.get("description"),
executed_by=str(user.id),
)
db.add(event)
await db.flush()
return success(data={"id": str(event.id)}, message="创建成功")
@router.post("/{intervention_id}/attribute", response_model=ApiResponse[dict])
async def attribute(intervention_id: str, req: dict, user: User = Depends(get_current_user)):
"""AI Alpha 归因分析。"""
result = await attribute_alpha(req.get("intervention", {}), req.get("metric_changes", {}))
return success(data=result)
+163
View File
@@ -0,0 +1,163 @@
"""认证路由:登录 / 注册 / 刷新 token / 获取当前用户。"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.auth import (
LoginRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
UserInfo,
)
from app.schemas.common import ApiResponse, success
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=ApiResponse[TokenResponse])
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""用户注册(创建新租户 + 首个用户)。"""
# 检查邮箱是否已存在
existing = await db.execute(select(User).where(User.email == req.email))
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="邮箱已注册",
)
# 创建租户
tenant = Tenant(name=req.tenant_name, type="vc")
db.add(tenant)
await db.flush()
# 创建用户
user = User(
tenant_id=tenant.id,
email=req.email,
password_hash=hash_password(req.password),
name=req.name,
role=req.role,
)
db.add(user)
await db.flush()
access_token = create_access_token(
subject=user.id,
extra_claims={"role": user.role, "tenant_id": user.tenant_id},
)
refresh_token = create_refresh_token(subject=user.id)
return success(
data=TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.jwt_access_token_ttl_minutes * 60,
),
message="注册成功",
)
@router.post("/login", response_model=ApiResponse[TokenResponse])
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
"""用户登录。"""
result = await db.execute(select(User).where(User.email == req.email))
user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="邮箱或密码错误",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账户已禁用",
)
access_token = create_access_token(
subject=user.id,
extra_claims={"role": user.role, "tenant_id": user.tenant_id},
)
refresh_token = create_refresh_token(subject=user.id)
return success(
data=TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.jwt_access_token_ttl_minutes * 60,
),
message="登录成功",
)
@router.post("/refresh", response_model=ApiResponse[TokenResponse])
async def refresh_token(req: RefreshRequest, db: AsyncSession = Depends(get_db)):
"""刷新 access token。"""
try:
payload = decode_token(req.refresh_token)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 refresh token",
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 token 类型",
)
user_id = payload.get("sub")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在或已禁用",
)
access_token = create_access_token(
subject=user.id,
extra_claims={"role": user.role, "tenant_id": user.tenant_id},
)
new_refresh_token = create_refresh_token(subject=user.id)
return success(
data=TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
expires_in=settings.jwt_access_token_ttl_minutes * 60,
),
message="刷新成功",
)
@router.get("/me", response_model=ApiResponse[UserInfo])
async def get_me(user: User = Depends(get_current_user)):
"""获取当前用户信息。"""
return success(
data=UserInfo(
id=user.id,
email=user.email,
name=user.name,
role=user.role,
tenant_id=user.tenant_id,
is_active=user.is_active,
),
)
+96
View File
@@ -0,0 +1,96 @@
"""董事会路由:CRUD + 决议追踪。"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import 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.board import BoardMeeting
from app.models.company import Company
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.board_agent import generate_meeting_summary, generate_questions
router = APIRouter(prefix="/board", tags=["board"])
@router.get("", response_model=ApiResponse[list])
async def list_board_meetings(
company_id: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取董事会会议列表。"""
query = (
select(BoardMeeting)
.join(Company, BoardMeeting.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(BoardMeeting.company_id == company_id)
result = await db.execute(query.order_by(BoardMeeting.meeting_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"title": i.title,
"meeting_at": i.meeting_at.isoformat() if i.meeting_at else None,
"status": i.status,
"agenda": i.agenda,
"materials_summary": i.materials_summary,
"minutes": i.minutes,
"resolutions": i.resolutions,
"questions": i.questions,
}
for i in items
])
@router.post("", response_model=ApiResponse[dict], status_code=status.HTTP_201_CREATED)
async def create_board_meeting(
req: dict,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""创建董事会会议。"""
company_result = await db.execute(
select(Company).where(Company.id == req.get("company_id"), Company.tenant_id == user.tenant_id)
)
if not company_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
meeting = BoardMeeting(
company_id=req.get("company_id"),
title=req.get("title"),
meeting_at=req.get("meeting_at"),
agenda=req.get("agenda"),
)
db.add(meeting)
await db.flush()
return success(data={"id": str(meeting.id)}, message="创建成功")
@router.post("/{meeting_id}/generate-summary", response_model=ApiResponse[str])
async def generate_meeting_summary_endpoint(
meeting_id: str,
req: dict,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""AI 生成会前材料摘要。"""
summary = await generate_meeting_summary(req.get("materials_text", ""))
return success(data=summary)
@router.post("/{meeting_id}/generate-questions", response_model=ApiResponse[list])
async def generate_questions_endpoint(
meeting_id: str,
req: dict,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""AI 生成提问清单。"""
questions = await generate_questions(req.get("materials_text", ""))
return success(data=questions)
+394
View File
@@ -0,0 +1,394 @@
"""企业档案路由:CRUD + 列表分页。"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
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.user import User
from app.schemas.common import ApiResponse, success
from app.schemas.company import (
CompanyCreate,
CompanyDetailResponse,
CompanyListResponse,
CompanyResponse,
CompanyUpdate,
AgreementBrief,
BoardMeetingBrief,
HealthScoreBrief,
HealthScoreHistoryPoint,
MajorEventBrief,
MilestoneBrief,
ReportBrief,
RiskBrief,
SynergyBrief,
TeamMemberBrief,
WeakSignalBrief,
)
from app.models.agreement import InvestmentAgreement
from app.models.board import BoardMeeting
from app.models.financial_data import FinancialData
from app.models.health_score import HealthScore
from app.models.major_event import MajorEvent
from app.models.milestone import MilestoneTree
from app.models.report import MonthlyReport
from app.models.risk import RiskEvent
from app.models.synergy import SynergyOpportunity
from app.models.talent import TeamMember
from app.models.weak_signal import WeakSignal
router = APIRouter(prefix="/companies", tags=["companies"])
@router.get("", response_model=ApiResponse[CompanyListResponse])
async def list_companies(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
keyword: str | None = Query(default=None, description="按名称搜索"),
industry: str | None = Query(default=None, description="按行业筛选"),
stage: str | None = Query(default=None, description="按融资阶段筛选"),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取企业列表(分页 + 筛选)。"""
query = select(Company).where(Company.tenant_id == user.tenant_id)
if keyword:
query = query.where(Company.name.ilike(f"%{keyword}%"))
if industry:
query = query.where(Company.industry == industry)
if stage:
query = query.where(Company.stage == stage)
# 总数
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar_one()
# 分页
offset = (page - 1) * page_size
query = query.order_by(Company.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
companies = result.scalars().all()
return success(
data=CompanyListResponse(
items=[CompanyResponse.model_validate(c, from_attributes=True) for c in companies],
total=total,
page=page,
page_size=page_size,
)
)
@router.get("/{company_id}", response_model=ApiResponse[CompanyResponse])
async def get_company(
company_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取企业详情。"""
result = await db.execute(
select(Company).where(Company.id == company_id, Company.tenant_id == user.tenant_id)
)
company = result.scalar_one_or_none()
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
return success(data=CompanyResponse.model_validate(company, from_attributes=True))
@router.get("/{company_id}/detail", response_model=ApiResponse[CompanyDetailResponse])
async def get_company_detail(
company_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取企业详情聚合数据 — 工作台使用。
聚合:企业基本信息 + 最新健康度 + 最近月报 + 未解决风险 + 弱信号 + 活跃协议 + 董事会会议 + 财务数据。
"""
# 企业基本信息
result = await db.execute(
select(Company).where(Company.id == company_id, Company.tenant_id == user.tenant_id)
)
company = result.scalar_one_or_none()
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
# 最新健康度评分
health_result = await db.execute(
select(HealthScore)
.where(HealthScore.company_id == company_id)
.order_by(HealthScore.calculated_at.desc())
.limit(1)
)
health = health_result.scalar_one_or_none()
health_brief = HealthScoreBrief(
total_score=health.total_score,
financial_score=health.financial_score,
operational_score=health.operational_score,
ai_commercial_score=health.ai_commercial_score,
ai_cost_score=health.ai_cost_score,
org_talent_score=getattr(health, "org_talent_score", None),
product_tech_score=getattr(health, "product_tech_score", None),
market_compete_score=getattr(health, "market_compete_score", None),
governance_score=getattr(health, "governance_score", None),
financing_score=getattr(health, "financing_score", None),
synergy_score=getattr(health, "synergy_score", None),
ai_model_product_score=getattr(health, "ai_model_product_score", None),
data_compliance_score=getattr(health, "data_compliance_score", None),
team_tech_score=getattr(health, "team_tech_score", None),
customer_success_score=getattr(health, "customer_success_score", None),
trend=health.trend,
calculated_at=health.calculated_at,
) if health else None
# 最近 5 条月报
reports_result = await db.execute(
select(MonthlyReport)
.where(MonthlyReport.company_id == company_id)
.order_by(MonthlyReport.period_year.desc(), MonthlyReport.period_month.desc())
.limit(5)
)
reports = reports_result.scalars().all()
report_briefs = [
ReportBrief(
id=r.id, period_year=r.period_year, period_month=r.period_month,
status=r.status, ai_summary=r.ai_summary, submitted_at=r.submitted_at,
) for r in reports
]
# 未解决风险
risks_result = await db.execute(
select(RiskEvent)
.where(RiskEvent.company_id == company_id, RiskEvent.status.in_(["open", "assigned", "in_progress"]))
.order_by(RiskEvent.identified_at.desc())
.limit(10)
)
risks = risks_result.scalars().all()
risk_briefs = [
RiskBrief(
id=r.id, type=r.type, severity=r.severity, status=r.status,
title=r.title, identified_at=r.identified_at,
) for r in risks
]
# 最近弱信号
signals_result = await db.execute(
select(WeakSignal)
.where(WeakSignal.company_id == company_id)
.order_by(WeakSignal.detected_at.desc())
.limit(10)
)
signals = signals_result.scalars().all()
signal_briefs = [
WeakSignalBrief(
id=s.id, signal_type=s.signal_type, content=s.content,
confidence=s.confidence, risk_probability=s.risk_probability,
status=s.status, detected_at=s.detected_at,
) for s in signals
]
# 活跃协议
agreements_result = await db.execute(
select(InvestmentAgreement)
.where(InvestmentAgreement.company_id == company_id, InvestmentAgreement.status == "active")
.order_by(InvestmentAgreement.created_at.desc())
)
agreements = agreements_result.scalars().all()
agreement_briefs = [
AgreementBrief(id=a.id, title=a.title, status=a.status, signed_at=a.signed_at)
for a in agreements
]
# 最近董事会会议
board_result = await db.execute(
select(BoardMeeting)
.where(BoardMeeting.company_id == company_id)
.order_by(BoardMeeting.created_at.desc())
.limit(5)
)
meetings = board_result.scalars().all()
meeting_briefs = [
BoardMeetingBrief(id=m.id, title=m.title, status=m.status, meeting_at=m.meeting_at)
for m in meetings
]
# 财务数据统计
fin_count_result = await db.execute(
select(func.count()).select_from(
select(FinancialData).where(FinancialData.company_id == company_id).subquery()
)
)
fin_count = fin_count_result.scalar_one()
latest_fin_result = await db.execute(
select(FinancialData)
.where(FinancialData.company_id == company_id)
.order_by(FinancialData.period_year.desc(), FinancialData.period_month.desc())
.limit(1)
)
latest_fin = latest_fin_result.scalar_one_or_none()
latest_financial = latest_fin.data_json if latest_fin else None
# 健康度历史趋势(最近 12 条)
history_result = await db.execute(
select(HealthScore)
.where(HealthScore.company_id == company_id)
.order_by(HealthScore.calculated_at.asc())
.limit(12)
)
history_scores = history_result.scalars().all()
health_score_history = [
HealthScoreHistoryPoint(
period=s.calculated_at.strftime("%Y-%m"),
total_score=s.total_score,
calculated_at=s.calculated_at,
)
for s in history_scores
]
# 重大事项(最近 10 条)
events_result = await db.execute(
select(MajorEvent)
.where(MajorEvent.company_id == company_id)
.order_by(MajorEvent.created_at.desc())
.limit(10)
)
events = events_result.scalars().all()
major_events = [
MajorEventBrief(
id=e.id, event_type=e.event_type, title=e.title,
description=e.description, severity=e.severity,
status=e.status, occurred_at=e.occurred_at,
) for e in events
]
# 里程碑
milestone_result = await db.execute(
select(MilestoneTree)
.where(MilestoneTree.company_id == company_id)
.order_by(MilestoneTree.target_date.desc().nulls_last())
.limit(20)
)
milestones = milestone_result.scalars().all()
milestone_briefs = [
MilestoneBrief(
id=m.id, name=m.name, status=m.status, is_current=m.is_current,
target_date=m.target_date, actual_date=m.actual_date,
description=m.description,
) for m in milestones
]
# 团队成员
team_result = await db.execute(
select(TeamMember)
.where(TeamMember.company_id == company_id)
.order_by(TeamMember.is_key_person.desc(), TeamMember.joined_at.desc())
.limit(20)
)
team_members_data = team_result.scalars().all()
team_members = [
TeamMemberBrief(
id=t.id, name=t.name, role=t.role, is_key_person=t.is_key_person,
stability_score=t.stability_score, joined_at=t.joined_at,
) for t in team_members_data
]
# 协同机会(涉及该企业的)
synergy_result = await db.execute(
select(SynergyOpportunity)
.where(
(SynergyOpportunity.company_a_id == company_id) |
(SynergyOpportunity.company_b_id == company_id)
)
.order_by(SynergyOpportunity.created_at.desc())
.limit(10)
)
synergies = synergy_result.scalars().all()
synergy_briefs = [
SynergyBrief(
id=s.id, type=s.type, title=s.title,
description=s.description, status=s.status,
match_reason=s.match_reason,
) for s in synergies
]
return success(data=CompanyDetailResponse(
company=CompanyResponse.model_validate(company, from_attributes=True),
health_score=health_brief,
health_score_history=health_score_history,
recent_reports=report_briefs,
open_risks=risk_briefs,
recent_weak_signals=signal_briefs,
active_agreements=agreement_briefs,
recent_board_meetings=meeting_briefs,
major_events=major_events,
milestones=milestone_briefs,
team_members=team_members,
synergy_opportunities=synergy_briefs,
financial_data_count=fin_count,
latest_financial=latest_financial,
))
@router.post("", response_model=ApiResponse[CompanyResponse], status_code=status.HTTP_201_CREATED)
async def create_company(
req: CompanyCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""创建企业。"""
company = Company(tenant_id=user.tenant_id, **req.model_dump())
db.add(company)
await db.flush()
return success(
data=CompanyResponse.model_validate(company, from_attributes=True),
message="创建成功",
)
@router.put("/{company_id}", response_model=ApiResponse[CompanyResponse])
async def update_company(
company_id: str,
req: CompanyUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""更新企业信息。"""
result = await db.execute(
select(Company).where(Company.id == company_id, Company.tenant_id == user.tenant_id)
)
company = result.scalar_one_or_none()
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
update_data = req.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(company, key, value)
await db.flush()
return success(
data=CompanyResponse.model_validate(company, from_attributes=True),
message="更新成功",
)
@router.delete("/{company_id}", response_model=ApiResponse[None])
async def delete_company(
company_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""删除企业。"""
result = await db.execute(
select(Company).where(Company.id == company_id, Company.tenant_id == user.tenant_id)
)
company = result.scalar_one_or_none()
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
await db.delete(company)
return success(message="删除成功")
+101
View File
@@ -0,0 +1,101 @@
"""AI Copilot 路由 — SSE 流式对话。
为投资人和创始人提供 AI 副驾驶对话能力。
"""
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.models.user import User
from app.services.llm_client import llm_client
router = APIRouter(prefix="/copilot", tags=["copilot"])
class CopilotMessage(BaseModel):
"""Copilot 对话请求。"""
message: str = Field(..., min_length=1, max_length=4000)
context: dict | None = Field(default=None, description="可选上下文(企业ID/月报ID等)")
SYSTEM_PROMPT_INVESTOR = """你是 AIPortPilot 投后管理系统的 AI 副驾驶,服务于投资人用户。
你的职责:
1. 分析被投企业的经营状况和财务健康度
2. 识别潜在风险并提供预警建议
3. 协助撰写投后管理报告和建议
4. 解答关于企业治理、融资策略的问题
回答要求:
- 专业、简洁、有数据支撑
- 如需引用数据,明确标注来源
- 对于不确定的信息,坦诚说明
- 使用中文回答"""
SYSTEM_PROMPT_FOUNDER = """你是 AIPortPilot 投后管理系统的 AI 副驾驶,服务于创始人用户。
你的职责:
1. 协助撰写和优化月报内容
2. 分析企业经营数据,提供改进建议
3. 解答融资、团队管理、业务增长等问题
4. 提供行业趋势和竞品分析参考
回答要求:
- 实用、可操作、接地气
- 关注创始人的实际痛点
- 使用中文回答"""
@router.post("/chat")
async def copilot_chat(
req: CopilotMessage,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""AI Copilot 对话(SSE 流式输出)。
根据用户角色使用不同的 system prompt。
"""
system_prompt = (
SYSTEM_PROMPT_FOUNDER if user.role == "founder" else SYSTEM_PROMPT_INVESTOR
)
# 构建上下文
context_str = ""
if req.context:
context_parts = []
for key, value in req.context.items():
context_parts.append(f"{key}: {value}")
context_str = f"\n\n当前上下文:\n" + "\n".join(context_parts)
messages = [
{"role": "system", "content": system_prompt + context_str},
{"role": "user", "content": f"<<<USER_INPUT>>>\n{req.message}\n<<<END_USER_INPUT>>>"},
]
async def event_stream():
"""SSE 流式输出。"""
try:
async for token in llm_client.chat_stream(messages, temperature=0.7, max_tokens=2000):
yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+43
View File
@@ -0,0 +1,43 @@
"""客户增长路由。"""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import 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.customer_plan import CustomerAcquisitionPlan
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.customer_growth_agent import generate_customer_plan
router = APIRouter(prefix="/customer-plans", tags=["customer-plans"])
@router.get("", response_model=ApiResponse[list])
async def list_plans(company_id: str | None = Query(default=None), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取客户获取方案列表。"""
query = select(CustomerAcquisitionPlan)
if company_id:
query = query.where(CustomerAcquisitionPlan.company_id == company_id)
result = await db.execute(query.order_by(CustomerAcquisitionPlan.created_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"target_customer": i.target_customer,
"entry_angle": i.entry_angle,
"pricing_strategy": i.pricing_strategy,
"execution_status": i.execution_status,
"result": i.result,
}
for i in items
])
@router.post("/generate", response_model=ApiResponse[dict])
async def generate_plan(req: dict, user: User = Depends(get_current_user)):
"""AI 生成客户获取方案。"""
result = await generate_customer_plan(req.get("company_context", ""), req.get("lp_resources", ""))
return success(data=result)
+35
View File
@@ -0,0 +1,35 @@
"""客户成功运营路由 — QBR + Expansion + Churn Risk。"""
from fastapi import APIRouter, Depends, Query
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.schemas.common import ApiResponse, success
from app.services.qbr_generator import generate_qbr
from app.services.expansion_play import identify_expansion_opportunities
from app.services.churn_risk_detector import detect_churn_risk
router = APIRouter(prefix="/customer-success", tags=["customer-success"])
@router.post("/qbr", response_model=ApiResponse[dict])
async def generate_qbr_report(req: dict, user: User = Depends(get_current_user)):
"""自动生成 QBR 季度业务回顾。"""
result = await generate_qbr(req.get("company_id", ""), req.get("quarter_data", ""))
return success(data=result)
@router.post("/expansion", response_model=ApiResponse[list])
async def identify_expansion(req: dict, user: User = Depends(get_current_user)):
"""识别扩展机会。"""
result = await identify_expansion_opportunities(req.get("company_data", ""))
return success(data=result)
@router.get("/churn-risk", response_model=ApiResponse[list])
async def get_churn_risk(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取流失风险预警。"""
result = await detect_churn_risk(db, user.tenant_id)
return success(data=result)
+300
View File
@@ -0,0 +1,300 @@
"""健康度评分 + 仪表盘路由。"""
from fastapi import APIRouter, Depends, Query
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.health_score import DashboardSummary, HealthScoreResponse
from app.services.predictor import predict_trend, detect_anomalies
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
# 14 维度 key 列表
_DIMENSION_KEYS = [
"financial_score", "operational_score", "ai_commercial_score", "ai_cost_score",
"org_talent_score", "product_tech_score", "market_compete_score", "governance_score",
"financing_score", "synergy_score", "ai_model_product_score",
"data_compliance_score", "team_tech_score", "customer_success_score",
]
@router.get("/summary", response_model=ApiResponse[DashboardSummary])
async def get_dashboard_summary(
company_id: str | None = Query(default=None, description="指定企业 ID,不传则汇总全租户"),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取仪表盘汇总数据。支持按企业过滤。"""
tenant_id = user.tenant_id
# 企业总数(单企业视角时为 1
if company_id:
total_companies = 1
else:
companies_result = await db.execute(
select(func.count()).select_from(Company).where(Company.tenant_id == tenant_id)
)
total_companies = companies_result.scalar_one()
# 平均健康度(按企业过滤时只算该企业)
avg_query = (
select(func.avg(HealthScore.total_score))
.join(Company, HealthScore.company_id == Company.id)
.where(Company.tenant_id == tenant_id)
)
if company_id:
avg_query = avg_query.where(HealthScore.company_id == company_id)
avg_result = await db.execute(avg_query)
avg_score = avg_result.scalar_one()
avg_health_score = float(avg_score) if avg_score else 0.0
# 高风险事件数
risk_query = (
select(func.count())
.select_from(RiskEvent)
.join(Company, RiskEvent.company_id == Company.id)
.where(Company.tenant_id == tenant_id, RiskEvent.status.in_(["open", "assigned", "in_progress"]))
)
if company_id:
risk_query = risk_query.where(RiskEvent.company_id == company_id)
risk_result = await db.execute(risk_query)
high_risk_count = risk_result.scalar_one()
# 待审阅月报数
pending_query = (
select(func.count())
.select_from(MonthlyReport)
.join(Company, MonthlyReport.company_id == Company.id)
.where(Company.tenant_id == tenant_id, MonthlyReport.status.in_(["submitted", "ai_parsed"]))
)
if company_id:
pending_query = pending_query.where(MonthlyReport.company_id == company_id)
pending_result = await db.execute(pending_query)
pending_reports = pending_result.scalar_one()
# 最近评分 — 每家企业只取最新一条(避免历史数据导致企业重复)
subq = (
select(
HealthScore.company_id,
func.max(HealthScore.calculated_at).label("max_at"),
)
.join(Company, HealthScore.company_id == Company.id)
.where(Company.tenant_id == tenant_id)
.group_by(HealthScore.company_id)
)
if company_id:
subq = subq.where(HealthScore.company_id == company_id)
subq = subq.subquery()
recent_query = (
select(HealthScore, Company.name.label("company_name"))
.join(Company, HealthScore.company_id == Company.id)
.join(subq, (HealthScore.company_id == subq.c.company_id) & (HealthScore.calculated_at == subq.c.max_at))
.order_by(HealthScore.calculated_at.desc())
.limit(10)
)
recent_result = await db.execute(recent_query)
recent_rows = recent_result.all()
recent_scores = []
for row in recent_rows:
score = row[0]
company_name = row[1]
resp = HealthScoreResponse.model_validate(score, from_attributes=True)
resp.company_name = company_name
recent_scores.append(resp)
return success(
data=DashboardSummary(
total_companies=total_companies,
avg_health_score=round(avg_health_score, 1),
high_risk_count=high_risk_count,
pending_reports=pending_reports,
recent_scores=recent_scores,
)
)
@router.get("/scores", response_model=ApiResponse[list[HealthScoreResponse]])
async def list_health_scores(
company_id: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取健康度评分列表 — 每家企业只返回最新一条。"""
subq = (
select(
HealthScore.company_id,
func.max(HealthScore.calculated_at).label("max_at"),
)
.join(Company, HealthScore.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
.group_by(HealthScore.company_id)
)
if company_id:
subq = subq.where(HealthScore.company_id == company_id)
subq = subq.subquery()
query = (
select(HealthScore, Company.name.label("company_name"))
.join(Company, HealthScore.company_id == Company.id)
.join(subq, (HealthScore.company_id == subq.c.company_id) & (HealthScore.calculated_at == subq.c.max_at))
.order_by(HealthScore.calculated_at.desc())
.limit(limit)
)
result = await db.execute(query)
scores = []
for row in result.all():
score = row[0]
company_name = row[1]
resp = HealthScoreResponse.model_validate(score, from_attributes=True)
resp.company_name = company_name
scores.append(resp)
return success(data=scores)
@router.get("/heatmap", response_model=ApiResponse[list[dict]])
async def get_health_heatmap(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取健康度热力图数据 — 企业 × 维度评分矩阵。
返回格式:[{ company_id, company_name, scores: { dimension: score } }]
"""
# 获取租户下所有企业
companies_result = await db.execute(
select(Company).where(Company.tenant_id == user.tenant_id).order_by(Company.name)
)
companies = companies_result.scalars().all()
# 获取每个企业最新评分
heatmap = []
for company in companies:
score_result = await db.execute(
select(HealthScore)
.where(HealthScore.company_id == company.id)
.order_by(HealthScore.calculated_at.desc())
.limit(1)
)
score = score_result.scalar_one_or_none()
scores_dict = {}
if score:
for dim_key in _DIMENSION_KEYS:
val = getattr(score, dim_key, None)
if val is not None:
scores_dict[dim_key] = val
heatmap.append({
"company_id": company.id,
"company_name": company.name,
"total_score": score.total_score if score else None,
"scores": scores_dict,
})
return success(data=heatmap)
@router.get("/trends", response_model=ApiResponse[list[dict]])
async def get_health_trends(
company_id: str | None = Query(default=None, description="指定企业 ID,不传则返回全部企业"),
months: int = Query(default=6, ge=1, le=24, description="趋势月数"),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取健康度趋势数据 — 按企业分组,每家企业一条折线。
返回格式:[{ company_id, company_name, data: [{ period, score }] }]
"""
query = (
select(HealthScore, Company.name.label("company_name"))
.join(Company, HealthScore.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(HealthScore.company_id == company_id)
query = query.order_by(HealthScore.calculated_at.asc()).limit(months * 100)
result = await db.execute(query)
# 按企业分组
by_company: dict[str, dict[str, list]] = {}
for row in result.all():
score = row[0]
cname = row[1]
cid = str(score.company_id)
if cid not in by_company:
by_company[cid] = {"company_name": cname, "scores": []}
by_company[cid]["scores"].append(score)
trends = []
for cid, info in by_company.items():
# 每月取最新一条
monthly: dict[str, float] = {}
for s in info["scores"]:
period = s.calculated_at.strftime("%Y-%m")
monthly[period] = s.total_score
data_points = [
{"period": p, "score": round(v, 1)}
for p, v in sorted(monthly.items())
]
trends.append({
"company_id": cid,
"company_name": info["company_name"],
"data": data_points,
})
return success(data=trends)
@router.get("/forecasts", response_model=ApiResponse[dict])
async def get_health_forecasts(
company_id: str | None = Query(default=None, description="指定企业 ID,不传则汇总全租户"),
months_ahead: int = Query(default=3, ge=1, le=6, description="预测月数"),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取健康度预测数据 — 基于历史评分预测未来趋势 + 异常检测。
返回格式:{ predictions: [...], trend_direction, confidence, anomalies: [...] }
"""
query = (
select(HealthScore)
.join(Company, HealthScore.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(HealthScore.company_id == company_id)
query = query.order_by(HealthScore.calculated_at.asc()).limit(24)
result = await db.execute(query)
scores = result.scalars().all()
historical = [s.total_score for s in scores]
forecast = predict_trend(historical, months_ahead)
# 异常检测 — 各维度
anomalies_by_dim: dict[str, list[int]] = {}
for dim_key in _DIMENSION_KEYS:
dim_values = [getattr(s, dim_key) for s in scores if getattr(s, dim_key) is not None]
if len(dim_values) >= 3:
dim_anomalies = detect_anomalies(dim_values)
if dim_anomalies:
anomalies_by_dim[dim_key] = dim_anomalies
return success(data={
"predictions": forecast.get("predicted", []),
"slope": forecast.get("slope", 0),
"confidence": forecast.get("confidence", 0),
"trend_direction": "up" if forecast.get("slope", 0) > 1 else "down" if forecast.get("slope", 0) < -1 else "stable",
"anomalies": anomalies_by_dim,
"historical_count": len(historical),
})
+54
View File
@@ -0,0 +1,54 @@
"""数据源管理路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.data_source import DataSource
from app.models.user import User
from app.schemas.common import ApiResponse, success
router = APIRouter(prefix="/data-sources", tags=["data-sources"])
@router.get("", response_model=ApiResponse[list])
async def list_data_sources(
company_id: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取数据源列表。"""
query = select(DataSource).where(DataSource.tenant_id == user.tenant_id)
if company_id:
query = query.where(DataSource.company_id == company_id)
result = await db.execute(query)
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id) if i.company_id else None,
"source_type": i.source_type,
"name": i.name,
"status": i.status,
"last_synced_at": i.last_synced_at.isoformat() if i.last_synced_at else None,
}
for i in items
])
@router.post("", response_model=ApiResponse[dict])
async def create_data_source(req: dict, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""配置数据源。"""
ds = DataSource(
tenant_id=user.tenant_id,
company_id=req.get("company_id"),
source_type=req.get("source_type"),
name=req.get("name"),
api_endpoint=req.get("api_endpoint"),
config=req.get("config"),
)
db.add(ds)
await db.flush()
return success(data={"id": str(ds.id)}, message="创建成功")
+68
View File
@@ -0,0 +1,68 @@
"""决策前哨路由:CRUD + 场景分析查询。"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import 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.decision_sentinel import DecisionSentinel
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.decision_sentinel_agent import analyze_scenarios, identify_decision_points
router = APIRouter(prefix="/decision-sentinels", tags=["decision-sentinels"])
@router.get("", response_model=ApiResponse[list])
async def list_sentinels(
company_id: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取决策前哨列表。"""
query = (
select(DecisionSentinel)
.join(Company, DecisionSentinel.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(DecisionSentinel.company_id == company_id)
result = await db.execute(query.order_by(DecisionSentinel.identified_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"decision_type": i.decision_type,
"title": i.title,
"description": i.description,
"signals": i.signals,
"scenarios": i.scenarios,
"status": i.status,
"identified_at": i.identified_at.isoformat(),
}
for i in items
])
@router.post("/identify", response_model=ApiResponse[list])
async def identify_sentinels(
req: dict,
user: User = Depends(get_current_user),
):
"""AI 识别决策岔路口。"""
points = await identify_decision_points(req.get("company_context", ""))
return success(data=points)
@router.post("/{sentinel_id}/analyze", response_model=ApiResponse[dict])
async def analyze_sentinel_scenarios(
sentinel_id: str,
req: dict,
user: User = Depends(get_current_user),
):
"""AI 生成场景分析。"""
scenarios = await analyze_scenarios(req.get("decision", {}))
return success(data=scenarios)
+47
View File
@@ -0,0 +1,47 @@
"""数字孪生路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.digital_twin import DigitalTwinModel
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.digital_twin_engine import build_twin_model, simulate_scenario
router = APIRouter(prefix="/digital-twins", tags=["digital-twins"])
@router.get("", response_model=ApiResponse[list])
async def list_twins(company_id: str = Query(...), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取数字孪生模型列表。"""
result = await db.execute(
select(DigitalTwinModel).where(DigitalTwinModel.company_id == company_id)
)
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"model_params": i.model_params,
"scenarios": i.scenarios,
"accuracy_score": i.accuracy_score,
}
for i in items
])
@router.post("/build", response_model=ApiResponse[dict])
async def build_twin(req: dict, user: User = Depends(get_current_user)):
"""构建数字孪生模型。"""
result = await build_twin_model(req.get("company_data", ""))
return success(data=result)
@router.post("/simulate", response_model=ApiResponse[dict])
async def simulate(req: dict, user: User = Depends(get_current_user)):
"""模拟决策场景。"""
result = await simulate_scenario(req.get("model_params", {}), req.get("scenario", ""))
return success(data=result)
+451
View File
@@ -0,0 +1,451 @@
"""评价模板管理 + 评分计算 API。
提供模板 CRUD、权重计算、评分历史查询等接口。
"""
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy import 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.evaluation_template import EvaluationTemplate
from app.models.user import User
from app.schemas.common import ApiResponse, error, success
from app.services.evaluation_engine import (
calculate_weighted_score,
compute_weights,
get_dimension_score_key,
)
router = APIRouter(prefix="/evaluation", tags=["evaluation"])
# --- 请求/响应模型 ---
class WeightComputeRequest(BaseModel):
"""权重计算请求。"""
fund_type: str = Field(..., description="基金类型")
fund_lifecycle: str = Field("investment", description="存续期阶段")
company_stage: str = Field("a", description="企业阶段")
industry: str = Field("ai", description="产业赛道")
strategy: str = Field("growth", description="投资策略")
investor_type: str = Field("investor", description="投资人类型")
class TemplateCreateRequest(BaseModel):
"""创建模板请求。"""
name: str = Field(..., description="模板名称")
fund_type: str = Field(..., description="基金类型")
fund_lifecycle: str = Field("investment", description="存续期阶段")
company_stage: str = Field("a", description="企业阶段")
industry: str = Field("ai", description="产业赛道")
strategy: str = Field("growth", description="投资策略")
investor_type: str = Field("investor", description="投资人类型")
weights_json: dict[str, float] | None = Field(None, description="自定义权重(不传则自动计算)")
is_default: bool = Field(False, description="是否为默认模板")
class ScoreCalculateRequest(BaseModel):
"""评分计算请求。"""
company_id: str = Field(..., description="企业 ID")
template_id: str | None = Field(None, description="模板 ID(不传则自动匹配)")
fund_type: str | None = Field(None, description="基金类型(无模板时用于自动匹配)")
fund_lifecycle: str | None = Field(None, description="存续期阶段")
company_stage: str | None = Field(None, description="企业阶段")
industry: str | None = Field(None, description="产业赛道")
strategy: str | None = Field(None, description="投资策略")
structured_data: dict[str, Any] = Field(default_factory=dict, description="月报结构化数据")
# --- API 端点 ---
@router.post("/weights/compute", response_model=ApiResponse[dict])
async def compute_evaluation_weights(
req: WeightComputeRequest,
user: User = Depends(get_current_user),
):
"""根据 6 轴参数实时计算权重(不持久化)。"""
result = compute_weights(
fund_type=req.fund_type,
fund_lifecycle=req.fund_lifecycle,
company_stage=req.company_stage,
industry=req.industry,
strategy=req.strategy,
investor_type=req.investor_type,
)
return success(data=result)
@router.get("/templates", response_model=ApiResponse[list])
async def list_templates(
fund_type: str | None = Query(default=None, description="基金类型筛选"),
fund_lifecycle: str | None = Query(default=None, description="存续期阶段筛选"),
company_stage: str | None = Query(default=None, description="企业阶段筛选"),
industry: str | None = Query(default=None, description="产业赛道筛选"),
strategy: str | None = Query(default=None, description="投资策略筛选"),
is_default: bool | None = Query(default=None, description="仅默认模板"),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取评价模板列表。"""
query = select(EvaluationTemplate).where(
EvaluationTemplate.tenant_id == user.tenant_id,
EvaluationTemplate.is_active == True, # noqa: E712
)
if fund_type:
query = query.where(EvaluationTemplate.fund_type == fund_type)
if fund_lifecycle:
query = query.where(EvaluationTemplate.fund_lifecycle == fund_lifecycle)
if company_stage:
query = query.where(EvaluationTemplate.company_stage == company_stage)
if industry:
query = query.where(EvaluationTemplate.industry == industry)
if strategy:
query = query.where(EvaluationTemplate.strategy == strategy)
if is_default is not None:
query = query.where(EvaluationTemplate.is_default == is_default)
query = query.order_by(EvaluationTemplate.fund_type, EvaluationTemplate.company_stage)
result = await db.execute(query)
templates = result.scalars().all()
return success(data=[
{
"id": str(t.id),
"name": t.name,
"fund_type": t.fund_type,
"fund_lifecycle": t.fund_lifecycle,
"company_stage": t.company_stage,
"industry": t.industry,
"strategy": t.strategy,
"investor_type": t.investor_type,
"weights": t.weights_json,
"enabled_dimensions": t.enabled_dimensions,
"disabled_dimensions": t.disabled_dimensions,
"custom_metrics": t.custom_metrics_json,
"is_default": t.is_default,
"version": t.version,
}
for t in templates
])
@router.get("/templates/{template_id}", response_model=ApiResponse[dict])
async def get_template(
template_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取单个评价模板详情。"""
result = await db.execute(
select(EvaluationTemplate).where(EvaluationTemplate.id == template_id)
)
tmpl = result.scalar_one_or_none()
if not tmpl:
return error(code=404, message="模板不存在")
return success(data={
"id": str(tmpl.id),
"name": tmpl.name,
"fund_type": tmpl.fund_type,
"fund_lifecycle": tmpl.fund_lifecycle,
"company_stage": tmpl.company_stage,
"industry": tmpl.industry,
"strategy": tmpl.strategy,
"investor_type": tmpl.investor_type,
"weights": tmpl.weights_json,
"enabled_dimensions": tmpl.enabled_dimensions,
"disabled_dimensions": tmpl.disabled_dimensions,
"custom_metrics": tmpl.custom_metrics_json,
"lp_focus_metrics": tmpl.lp_focus_metrics,
"regional_benchmark": tmpl.regional_benchmark,
"is_default": tmpl.is_default,
"is_active": tmpl.is_active,
"version": tmpl.version,
"created_at": tmpl.created_at.isoformat() if tmpl.created_at else None,
"updated_at": tmpl.updated_at.isoformat() if tmpl.updated_at else None,
})
@router.post("/templates", response_model=ApiResponse[dict])
async def create_template(
req: TemplateCreateRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""创建自定义评价模板。"""
# 如果未提供权重,自动计算
if req.weights_json is None:
result = compute_weights(
fund_type=req.fund_type,
fund_lifecycle=req.fund_lifecycle,
company_stage=req.company_stage,
industry=req.industry,
strategy=req.strategy,
investor_type=req.investor_type,
)
weights_json = result["weights"]
enabled_dims = result["enabled_dimensions"]
disabled_dims = result["disabled_dimensions"]
custom_metrics = {"metrics": result["custom_metrics"]}
else:
weights_json = req.weights_json
# 从权重 key 推导启用/禁用维度
all_dims = list(get_dimension_score_key(k).replace("_score", "") for k in weights_json)
enabled_dims = [k for k, v in weights_json.items() if v > 0]
disabled_dims = [k for k in all_dims if k not in enabled_dims]
custom_metrics = None
tmpl = EvaluationTemplate(
tenant_id=user.tenant_id,
name=req.name,
fund_type=req.fund_type,
fund_lifecycle=req.fund_lifecycle,
company_stage=req.company_stage,
industry=req.industry,
strategy=req.strategy,
investor_type=req.investor_type,
weights_json=weights_json,
enabled_dimensions=enabled_dims,
disabled_dimensions=disabled_dims,
custom_metrics_json=custom_metrics,
is_default=req.is_default,
is_active=True,
version=1,
created_by=user.id,
)
db.add(tmpl)
await db.flush()
return success(data={
"id": str(tmpl.id),
"name": tmpl.name,
"weights": tmpl.weights_json,
})
@router.post("/score", response_model=ApiResponse[dict])
async def calculate_score(
req: ScoreCalculateRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""根据模板和月报数据计算评价评分。"""
from app.models.health_score import HealthScore
from app.services.health_calculator import calculate_health_score
# 获取模板
tmpl: EvaluationTemplate | None = None
if req.template_id:
result = await db.execute(
select(EvaluationTemplate).where(EvaluationTemplate.id == req.template_id)
)
tmpl = result.scalar_one_or_none()
if not tmpl:
# 自动匹配模板
query = select(EvaluationTemplate).where(
EvaluationTemplate.tenant_id == user.tenant_id,
EvaluationTemplate.is_active == True, # noqa: E712
EvaluationTemplate.is_default == True,
)
if req.fund_type:
query = query.where(EvaluationTemplate.fund_type == req.fund_type)
if req.fund_lifecycle:
query = query.where(EvaluationTemplate.fund_lifecycle == req.fund_lifecycle)
if req.company_stage:
query = query.where(EvaluationTemplate.company_stage == req.company_stage)
if req.industry:
query = query.where(EvaluationTemplate.industry == req.industry)
result = await db.execute(query.limit(1))
tmpl = result.scalar_one_or_none()
# 计算各维度评分(复用现有 health_calculator
dimension_scores = calculate_health_score(req.structured_data)
# 如果有模板,使用模板权重计算加权总分
if tmpl:
weights = tmpl.weights_json
# 将维度评分转换为权重 key 格式
dim_scores_by_weight_key: dict[str, float] = {}
for weight_key in weights:
score_key = get_dimension_score_key(weight_key)
dim_scores_by_weight_key[weight_key] = dimension_scores.get(score_key, 0.0)
total_score = calculate_weighted_score(dim_scores_by_weight_key, weights)
# 保存评分记录
score_record = HealthScore(
company_id=req.company_id,
total_score=total_score,
financial_score=dimension_scores.get("financial_score"),
operational_score=dimension_scores.get("operational_score"),
ai_commercial_score=dimension_scores.get("ai_commercial_score"),
ai_cost_score=dimension_scores.get("ai_cost_score"),
org_talent_score=dimension_scores.get("org_talent_score"),
product_tech_score=dimension_scores.get("product_tech_score"),
market_compete_score=dimension_scores.get("market_compete_score"),
governance_score=dimension_scores.get("governance_score"),
financing_score=dimension_scores.get("financing_score"),
synergy_score=dimension_scores.get("synergy_score"),
ai_model_product_score=dimension_scores.get("ai_model_product_score"),
data_compliance_score=dimension_scores.get("data_compliance_score"),
team_tech_score=dimension_scores.get("team_tech_score"),
customer_success_score=dimension_scores.get("customer_success_score"),
template_id=tmpl.id,
fund_type=tmpl.fund_type,
fund_lifecycle=tmpl.fund_lifecycle,
company_stage=tmpl.company_stage,
industry=tmpl.industry,
strategy=tmpl.strategy,
evidence_json={"template_name": tmpl.name, "weights": weights},
)
db.add(score_record)
await db.flush()
return success(data={
"score_id": str(score_record.id),
"total_score": total_score,
"dimension_scores": {k: v for k, v in dimension_scores.items() if k != "total_score"},
"template": {
"id": str(tmpl.id),
"name": tmpl.name,
"weights": weights,
},
})
else:
# 无模板,使用默认计算
total_score = dimension_scores.get("total_score", 0.0)
return success(data={
"total_score": total_score,
"dimension_scores": {k: v for k, v in dimension_scores.items() if k != "total_score"},
"template": None,
})
@router.get("/scores", response_model=ApiResponse[list])
async def list_scores(
company_id: str | None = Query(default=None, description="企业 ID 筛选"),
template_id: str | None = Query(default=None, description="模板 ID 筛选"),
limit: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取评价评分历史。"""
from app.models.health_score import HealthScore
from app.models.company import Company
query = (
select(HealthScore)
.join(Company, HealthScore.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(HealthScore.company_id == company_id)
if template_id:
query = query.where(HealthScore.template_id == template_id)
query = query.order_by(HealthScore.calculated_at.desc()).limit(limit)
result = await db.execute(query)
scores = result.scalars().all()
return success(data=[
{
"id": str(s.id),
"company_id": s.company_id,
"total_score": s.total_score,
"financial_score": s.financial_score,
"operational_score": s.operational_score,
"ai_commercial_score": s.ai_commercial_score,
"ai_cost_score": s.ai_cost_score,
"org_talent_score": s.org_talent_score,
"product_tech_score": s.product_tech_score,
"market_compete_score": s.market_compete_score,
"governance_score": s.governance_score,
"financing_score": s.financing_score,
"synergy_score": s.synergy_score,
"ai_model_product_score": s.ai_model_product_score,
"data_compliance_score": s.data_compliance_score,
"team_tech_score": s.team_tech_score,
"customer_success_score": s.customer_success_score,
"trend": s.trend,
"template_id": s.template_id,
"fund_type": s.fund_type,
"fund_lifecycle": s.fund_lifecycle,
"company_stage": s.company_stage,
"industry": s.industry,
"strategy": s.strategy,
"calculated_at": s.calculated_at.isoformat() if s.calculated_at else None,
}
for s in scores
])
# --- 基金管理端点 ---
@router.get("/funds", response_model=ApiResponse[list])
async def list_funds(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取当前租户的基金列表。"""
from app.models.fund import Fund
result = await db.execute(
select(Fund)
.where(Fund.tenant_id == user.tenant_id, Fund.is_active == True) # noqa: E712
.order_by(Fund.established_date.desc())
)
funds = result.scalars().all()
return success(data=[
{
"id": str(f.id),
"name": f.name,
"fund_type": f.fund_type,
"strategy": f.strategy,
"established_date": f.established_date.isoformat() if f.established_date else None,
"total_lifespan_months": f.total_lifespan_months,
"investment_period_months": f.investment_period_months,
"current_lifecycle": f.current_lifecycle,
"lp_composition": f.lp_composition_json,
"primary_market": f.primary_market,
}
for f in funds
])
@router.post("/funds", response_model=ApiResponse[dict])
async def create_fund(
req: dict[str, Any],
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""创建基金。"""
from app.models.fund import Fund
from datetime import date
fund = Fund(
tenant_id=user.tenant_id,
name=req.get("name", ""),
fund_type=req.get("fund_type", "early_vc"),
strategy=req.get("strategy", "growth"),
established_date=date.fromisoformat(req["established_date"]) if req.get("established_date") else None,
total_lifespan_months=req.get("total_lifespan_months", 84),
investment_period_months=req.get("investment_period_months", 48),
lp_composition_json=req.get("lp_composition"),
primary_market=req.get("primary_market"),
)
db.add(fund)
await db.flush()
return success(data={
"id": str(fund.id),
"name": fund.name,
"current_lifecycle": fund.current_lifecycle,
})
+106
View File
@@ -0,0 +1,106 @@
"""重大事项 + 追问清单路由。"""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import 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.inquiry import InquiryList
from app.models.major_event import MajorEvent
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.event_detector import detect_major_events
from app.services.inquiry_generator import generate_inquiry_questions
router = APIRouter(prefix="/events", tags=["events"])
@router.get("", response_model=ApiResponse[list])
async def list_events(
company_id: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取重大事项列表。"""
query = (
select(MajorEvent)
.join(Company, MajorEvent.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(MajorEvent.company_id == company_id)
result = await db.execute(query.order_by(MajorEvent.created_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"event_type": i.event_type,
"title": i.title,
"description": i.description,
"severity": i.severity,
"source": i.source,
"evidence": i.evidence,
"status": i.status,
"occurred_at": i.occurred_at.isoformat() if i.occurred_at else None,
}
for i in items
])
@router.post("/detect", response_model=ApiResponse[list])
async def detect_events(
req: dict,
user: User = Depends(get_current_user),
):
"""AI 从月报中识别重大事项。"""
events = await detect_major_events(req.get("report_content", ""))
return success(data=events)
router_inquiries = APIRouter(prefix="/inquiries", tags=["inquiries"])
@router_inquiries.get("", response_model=ApiResponse[list])
async def list_inquiries(
company_id: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取追问清单列表。"""
query = (
select(InquiryList)
.join(Company, InquiryList.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(InquiryList.company_id == company_id)
result = await db.execute(query.order_by(InquiryList.sent_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"report_id": i.report_id,
"questions": i.questions,
"status": i.status,
"sent_at": i.sent_at.isoformat(),
"answered_at": i.answered_at.isoformat() if i.answered_at else None,
}
for i in items
])
@router_inquiries.post("/generate", response_model=ApiResponse[list])
async def generate_inquiries(
req: dict,
user: User = Depends(get_current_user),
):
"""AI 生成追问清单。"""
questions = await generate_inquiry_questions(
req.get("report_content", ""),
req.get("structured_data"),
)
return success(data=questions)
+54
View File
@@ -0,0 +1,54 @@
"""退出预测路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.exit_prediction import ExitPrediction
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.exit_predictor import predict_exit
router = APIRouter(prefix="/exit-predictions", tags=["exit-predictions"])
@router.get("", response_model=ApiResponse[list])
async def list_exit_predictions(
company_id: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取退出预测列表。"""
query = (
select(ExitPrediction)
.join(Company, ExitPrediction.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(ExitPrediction.company_id == company_id)
result = await db.execute(query.order_by(ExitPrediction.created_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"exit_path": i.exit_path,
"timing_window": i.timing_window,
"expected_return": i.expected_return,
"hold_return": i.hold_return,
"confidence": i.confidence,
"signals": i.signals,
"recommendation": i.recommendation,
}
for i in items
])
@router.post("/predict", response_model=ApiResponse[dict])
async def predict(req: dict, user: User = Depends(get_current_user)):
"""AI 退出时机预测。"""
result = await predict_exit(req.get("company_data", ""))
return success(data=result)
+89
View File
@@ -0,0 +1,89 @@
"""财务数据路由:CRUD + 校验。"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import 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.financial_data import FinancialData
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.financial_validator import validate_financial_data
router = APIRouter(prefix="/financial", tags=["financial"])
@router.get("", response_model=ApiResponse[list])
async def list_financial_data(
company_id: str = Query(...),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取企业财务数据列表。"""
result = await db.execute(
select(FinancialData)
.join(Company, FinancialData.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id, FinancialData.company_id == company_id)
.order_by(FinancialData.period_year.desc(), FinancialData.period_month.desc())
)
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"period_year": i.period_year,
"period_month": i.period_month,
"statement_type": i.statement_type,
"data_json": i.data_json,
"credibility_score": i.credibility_score,
"validation_result": i.validation_result,
}
for i in items
])
@router.post("", response_model=ApiResponse[dict], status_code=status.HTTP_201_CREATED)
async def create_financial_data(
req: dict,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""创建财务数据并自动校验。"""
company_id = req.get("company_id")
company_result = await db.execute(
select(Company).where(Company.id == company_id, Company.tenant_id == user.tenant_id)
)
if not company_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
fd = FinancialData(
company_id=company_id,
period_year=req.get("period_year"),
period_month=req.get("period_month"),
statement_type=req.get("statement_type", "balance_sheet"),
data_json=req.get("data_json"),
source=req.get("source"),
)
validation = await validate_financial_data(db, company_id, fd.period_year, fd.period_month)
fd.credibility_score = validation["credibility_score"]
fd.validation_result = validation
db.add(fd)
await db.flush()
return success(data={"id": str(fd.id), "credibility_score": fd.credibility_score, "validation_result": validation}, message="创建成功")
@router.get("/validate", response_model=ApiResponse[dict])
async def validate_financial(
company_id: str = Query(...),
period_year: int = Query(...),
period_month: int = Query(...),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""校验财务数据。"""
result = await validate_financial_data(db, company_id, period_year, period_month)
return success(data=result)
+148
View File
@@ -0,0 +1,148 @@
"""创始人专属 API — 经营概览 + 自身健康度 + AI 副驾驶完整版。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.user import User
from app.schemas.common import ApiResponse, success
from app.services.founder_copilot import financing_planner, org_diagnostic, investor_comm_prep
router = APIRouter(prefix="/founder", tags=["founder"])
@router.get("/overview", response_model=ApiResponse[dict])
async def founder_overview(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""创始人经营概览。"""
# 获取创始人关联的企业
company_result = await db.execute(
select(Company).where(Company.tenant_id == user.tenant_id).limit(1)
)
company = company_result.scalar_one_or_none()
if not company:
return success(data={"message": "暂无关联企业"})
# 获取最新健康度
health_result = await db.execute(
select(HealthScore)
.where(HealthScore.company_id == company.id)
.order_by(HealthScore.calculated_at.desc())
.limit(1)
)
health = health_result.scalar_one_or_none()
# 获取最新月报
report_result = await db.execute(
select(MonthlyReport)
.where(MonthlyReport.company_id == company.id)
.order_by(MonthlyReport.period_year.desc(), MonthlyReport.period_month.desc())
.limit(1)
)
report = report_result.scalar_one_or_none()
return success(data={
"company": {"id": str(company.id), "name": company.name, "industry": company.industry, "stage": company.stage},
"health_score": {
"total_score": health.total_score if health else None,
"trend": health.trend if health else None,
} if health else None,
"latest_report": {
"id": str(report.id),
"period": f"{report.period_year}-{report.period_month:02d}",
"status": report.status,
} if report else None,
})
@router.get("/health", response_model=ApiResponse[dict])
async def founder_health(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""创始人查看自身健康度。"""
company_result = await db.execute(
select(Company).where(Company.tenant_id == user.tenant_id).limit(1)
)
company = company_result.scalar_one_or_none()
if not company:
return success(data=None)
result = await db.execute(
select(HealthScore)
.where(HealthScore.company_id == company.id)
.order_by(HealthScore.calculated_at.desc())
.limit(1)
)
health = result.scalar_one_or_none()
if not health:
return success(data=None)
return success(data={
"total_score": health.total_score,
"financial_score": health.financial_score,
"operational_score": health.operational_score,
"ai_commercial_score": health.ai_commercial_score,
"ai_cost_score": health.ai_cost_score,
"trend": health.trend,
"recommendations": health.recommendations_json,
})
@router.post("/financing-plan", response_model=ApiResponse[dict])
async def founder_financing_plan(
req: dict,
user: User = Depends(get_current_user),
):
"""AI 融资规划 — 节奏/估值/投资人画像。
Args:
req: 包含 company_data 字段,描述企业当前融资情况
Returns:
AI 生成的融资规划建议
"""
result = await financing_planner(req.get("company_data", ""))
return success(data=result)
@router.post("/org-diagnostic", response_model=ApiResponse[dict])
async def founder_org_diagnostic(
req: dict,
user: User = Depends(get_current_user),
):
"""AI 组织诊断 — 团队结构/关键岗位风险/人才缺口。
Args:
req: 包含 team_data 字段,描述团队当前情况
Returns:
AI 生成的组织诊断报告
"""
result = await org_diagnostic(req.get("team_data", ""))
return success(data=result)
@router.post("/investor-comm-prep", response_model=ApiResponse[dict])
async def founder_investor_comm_prep(
req: dict,
user: User = Depends(get_current_user),
):
"""AI 投资人沟通准备 — 董事会材料/投资人问答。
Args:
req: 包含 board_context 字段,描述董事会/投资人会议背景
Returns:
AI 生成的投资人沟通准备材料
"""
result = await investor_comm_prep(req.get("board_context", ""))
return success(data=result)
+24
View File
@@ -0,0 +1,24 @@
"""基金管理路由。"""
from fastapi import APIRouter, Depends
from app.core.dependencies import get_current_user
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.fund_strategy_analyzer import analyze_fund_strategy
from app.services.lp_report_generator import generate_lp_report
router = APIRouter(prefix="/funds", tags=["funds"])
@router.post("/analyze-strategy", response_model=ApiResponse[dict])
async def analyze_strategy(req: dict, user: User = Depends(get_current_user)):
"""基金策略分析。"""
result = await analyze_fund_strategy(req.get("funds_data", ""))
return success(data=result)
@router.post("/lp-report", response_model=ApiResponse[str])
async def generate_lp(req: dict, user: User = Depends(get_current_user)):
"""LP 报告自动生成。"""
result = await generate_lp_report(req.get("fund_data", ""), req.get("portfolio_summary", ""))
return success(data=result)
+16
View File
@@ -0,0 +1,16 @@
"""行业研究路由。"""
from fastapi import APIRouter, Depends
from app.core.dependencies import get_current_user
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.industry_research_agent import research_industry
router = APIRouter(prefix="/industry-research", tags=["industry-research"])
@router.post("/research", response_model=ApiResponse[dict])
async def research(req: dict, user: User = Depends(get_current_user)):
"""AI 行业研究。"""
result = await research_industry(req.get("industry", ""), req.get("companies", ""))
return success(data=result)
+16
View File
@@ -0,0 +1,16 @@
"""组合创新路由。"""
from fastapi import APIRouter, Depends
from app.core.dependencies import get_current_user
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.innovation_lab import discover_innovation_opportunities
router = APIRouter(prefix="/innovation", tags=["innovation"])
@router.post("/discover", response_model=ApiResponse[list])
async def discover_innovation(req: dict, user: User = Depends(get_current_user)):
"""AI 发现组合创新机会。"""
results = await discover_innovation_opportunities(req.get("portfolio_capabilities", ""))
return success(data=results)
+32
View File
@@ -0,0 +1,32 @@
"""RAG 知识库路由。"""
from fastapi import APIRouter, Depends, Query
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.schemas.common import ApiResponse, success
from app.services.rag import semantic_search, build_context
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
@router.get("/search", response_model=ApiResponse[list])
async def search_knowledge(
q: str = Query(..., min_length=1),
top_k: int = Query(default=5, ge=1, le=20),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""语义搜索知识库。"""
results = await semantic_search(db, user.tenant_id, q, top_k)
return success(data=results)
@router.post("/context", response_model=ApiResponse[str])
async def get_context(req: dict, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""构建 RAG 上下文。"""
results = await semantic_search(db, user.tenant_id, req.get("query", ""))
context = await build_context(results)
return success(data=context)
+23
View File
@@ -0,0 +1,23 @@
"""知识图谱路由。"""
from fastapi import APIRouter, Depends
from app.core.dependencies import get_current_user
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.knowledge_graph_builder import build_knowledge_graph, match_best_strategy
router = APIRouter(prefix="/knowledge-graph", tags=["knowledge-graph"])
@router.post("/build", response_model=ApiResponse[dict])
async def build_graph(req: dict, user: User = Depends(get_current_user)):
"""构建知识图谱。"""
result = await build_knowledge_graph(req.get("management_experiences", ""))
return success(data=result)
@router.post("/match-strategy", response_model=ApiResponse[dict])
async def match_strategy(req: dict, user: User = Depends(get_current_user)):
"""为新企业匹配最佳管理策略。"""
result = await match_best_strategy(req.get("new_company_profile", ""), req.get("knowledge_graph", {}))
return success(data=result)
+45
View File
@@ -0,0 +1,45 @@
"""里程碑路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.milestone import MilestoneTree
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.milestone_agent import suggest_path_switch
router = APIRouter(prefix="/milestones", tags=["milestones"])
@router.get("", response_model=ApiResponse[list])
async def list_milestones(company_id: str = Query(...), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取里程碑树。"""
result = await db.execute(
select(MilestoneTree).where(MilestoneTree.company_id == company_id)
)
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"name": i.name,
"parent_id": i.parent_id,
"is_current": i.is_current,
"status": i.status,
"target_date": i.target_date.isoformat() if i.target_date else None,
"actual_date": i.actual_date.isoformat() if i.actual_date else None,
"description": i.description,
"ai_analysis": i.ai_analysis,
}
for i in items
])
@router.post("/suggest-switch", response_model=ApiResponse[dict])
async def suggest_switch(req: dict, user: User = Depends(get_current_user)):
"""AI 建议路径切换。"""
result = await suggest_path_switch(req.get("milestone_context", ""), req.get("env_changes", ""))
return success(data=result)
+42
View File
@@ -0,0 +1,42 @@
"""行为助推路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.nudge import NudgeRecord
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.nudge_agent import select_nudge_strategy
router = APIRouter(prefix="/nudges", tags=["nudges"])
@router.get("", response_model=ApiResponse[list])
async def list_nudges(company_id: str | None = Query(default=None), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取助推记录列表。"""
query = select(NudgeRecord)
if company_id:
query = query.where(NudgeRecord.company_id == company_id)
result = await db.execute(query.order_by(NudgeRecord.created_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"nudge_type": i.nudge_type,
"message": i.message,
"accepted": i.accepted,
"effect_result": i.effect_result,
}
for i in items
])
@router.post("/select", response_model=ApiResponse[dict])
async def select_nudge(req: dict, user: User = Depends(get_current_user)):
"""AI 选择助推策略。"""
result = await select_nudge_strategy(req.get("context", ""))
return success(data=result)
+59
View File
@@ -0,0 +1,59 @@
"""OKR 路由。"""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import 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.okr import OKR
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.okr_agent import track_okr_progress
router = APIRouter(prefix="/okrs", tags=["okrs"])
@router.get("", response_model=ApiResponse[list])
async def list_okrs(company_id: str | None = Query(default=None), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取 OKR 列表。"""
query = select(OKR)
if company_id:
query = query.where(OKR.company_id == company_id)
result = await db.execute(query.order_by(OKR.quarter.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"quarter": i.quarter,
"objective": i.objective,
"key_results": i.key_results,
"alignment_score": i.alignment_score,
"deviation_alerts": i.deviation_alerts,
"review_notes": i.review_notes,
"status": i.status,
}
for i in items
])
@router.post("", response_model=ApiResponse[dict], status_code=status.HTTP_201_CREATED)
async def create_okr(req: dict, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""创建 OKR。"""
okr = OKR(
company_id=req.get("company_id"),
quarter=req.get("quarter"),
objective=req.get("objective"),
key_results=req.get("key_results"),
)
db.add(okr)
await db.flush()
return success(data={"id": str(okr.id)}, message="创建成功")
@router.post("/{okr_id}/track", response_model=ApiResponse[dict])
async def track_okr(okr_id: str, req: dict, user: User = Depends(get_current_user)):
"""AI 追踪 KR 进展。"""
result = await track_okr_progress(req.get("key_results", []))
return success(data=result)
+43
View File
@@ -0,0 +1,43 @@
"""Peer Learning Circles 路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.peer_circle import PeerLearningCircle
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.peer_matching import match_founders
router = APIRouter(prefix="/peer-circles", tags=["peer-circles"])
@router.get("", response_model=ApiResponse[list])
async def list_circles(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取 Peer Learning Circle 列表。"""
result = await db.execute(
select(PeerLearningCircle).where(PeerLearningCircle.tenant_id == user.tenant_id)
)
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"topic": i.topic,
"description": i.description,
"members": i.members,
"discussion_framework": i.discussion_framework,
"conclusions": i.conclusions,
"action_commitments": i.action_commitments,
"status": i.status,
}
for i in items
])
@router.post("/match", response_model=ApiResponse[dict])
async def match_peer_circle(req: dict, user: User = Depends(get_current_user)):
"""AI 匹配创始人。"""
result = await match_founders(req.get("founders_context", ""))
return success(data=result)
+24
View File
@@ -0,0 +1,24 @@
"""组合管理路由 — 再平衡 + Monte Carlo。"""
from fastapi import APIRouter, Depends
from app.core.dependencies import get_current_user
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.portfolio_rebalancer import rebalance_portfolio
from app.services.monte_carlo import simulate_portfolio
router = APIRouter(prefix="/portfolio", tags=["portfolio"])
@router.post("/rebalance", response_model=ApiResponse[dict])
async def rebalance(req: dict, user: User = Depends(get_current_user)):
"""组合再平衡建议。"""
result = rebalance_portfolio(req.get("company_returns", []))
return success(data=result)
@router.post("/monte-carlo", response_model=ApiResponse[dict])
async def monte_carlo(req: dict, user: User = Depends(get_current_user)):
"""Monte Carlo 模拟。"""
result = await simulate_portfolio(req.get("company_returns", []), req.get("iterations", 10000))
return success(data=result)
+58
View File
@@ -0,0 +1,58 @@
"""Pre-mortem + Red Team 路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.pre_mortem import PreMortemRecord, RedTeamRecord
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.pre_mortem_agent import run_pre_mortem
from app.services.red_team_agent import run_red_team
router_pre_mortem = APIRouter(prefix="/pre-mortems", tags=["pre-mortems"])
router_red_team = APIRouter(prefix="/red-teams", tags=["red-teams"])
@router_pre_mortem.get("", response_model=ApiResponse[list])
async def list_pre_mortems(company_id: str | None = Query(default=None), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取 Pre-mortem 列表。"""
query = select(PreMortemRecord)
if company_id:
query = query.where(PreMortemRecord.company_id == company_id)
result = await db.execute(query.order_by(PreMortemRecord.created_at.desc()))
items = result.scalars().all()
return success(data=[
{"id": str(i.id), "company_id": str(i.company_id), "decision_context": i.decision_context, "failure_paths": i.failure_paths, "risk_checklist": i.risk_checklist, "mitigations": i.mitigations}
for i in items
])
@router_pre_mortem.post("/run", response_model=ApiResponse[dict])
async def run_pre_mortem_analysis(req: dict, user: User = Depends(get_current_user)):
"""AI Pre-mortem 失败推演。"""
result = await run_pre_mortem(req.get("decision_context", ""))
return success(data=result)
@router_red_team.get("", response_model=ApiResponse[list])
async def list_red_teams(company_id: str | None = Query(default=None), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取 Red Team 列表。"""
query = select(RedTeamRecord)
if company_id:
query = query.where(RedTeamRecord.company_id == company_id)
result = await db.execute(query.order_by(RedTeamRecord.created_at.desc()))
items = result.scalars().all()
return success(data=[
{"id": str(i.id), "company_id": str(i.company_id), "perspective": i.perspective, "analysis": i.analysis, "vulnerabilities": i.vulnerabilities, "counterarguments": i.counterarguments}
for i in items
])
@router_red_team.post("/run", response_model=ApiResponse[dict])
async def run_red_team_analysis(req: dict, user: User = Depends(get_current_user)):
"""AI Red Team 对抗分析。"""
result = await run_red_team(req.get("company_context", ""), req.get("perspective", "competitor"))
return success(data=result)
@@ -0,0 +1,43 @@
"""产品竞争力诊断路由。"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import 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.product_diagnostic import ProductDiagnostic
from app.models.user import User
from app.schemas.common import ApiResponse, success
from app.services.product_diagnostic_agent import diagnose_product
router = APIRouter(prefix="/product-diagnostics", tags=["product-diagnostics"])
@router.get("", response_model=ApiResponse[list])
async def list_diagnostics(company_id: str | None = Query(default=None), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取产品诊断列表。"""
query = select(ProductDiagnostic)
if company_id:
query = query.where(ProductDiagnostic.company_id == company_id)
result = await db.execute(query.order_by(ProductDiagnostic.created_at.desc()))
items = result.scalars().all()
return success(data=[
{
"id": str(i.id),
"company_id": str(i.company_id),
"product_name": i.product_name,
"dimensions": i.dimensions,
"heatmap_data": i.heatmap_data,
"competitors": i.competitors,
"roadmap_suggestions": i.roadmap_suggestions,
}
for i in items
])
@router.post("/diagnose", response_model=ApiResponse[dict])
async def diagnose(req: dict, user: User = Depends(get_current_user)):
"""AI 产品竞争力诊断。"""
result = await diagnose_product(req.get("product_info", ""), req.get("competitor_info", ""))
return success(data=result)
+72
View File
@@ -0,0 +1,72 @@
"""多主体画像路由。"""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy import 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.profile import FirmProfile, FundProfile, ManagerProfile
from app.models.user import User
from app.schemas.common import ApiResponse, success
router = APIRouter(prefix="/profiles", tags=["profiles"])
@router.get("/firms", response_model=ApiResponse[list])
async def list_firms(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取投资机构列表。"""
result = await db.execute(
select(FirmProfile).where(FirmProfile.tenant_id == user.tenant_id)
)
items = result.scalars().all()
return success(data=[
{"id": str(i.id), "name": i.name, "focus_areas": i.focus_areas, "stage_preference": i.stage_preference, "description": i.description}
for i in items
])
@router.post("/firms", response_model=ApiResponse[dict], status_code=status.HTTP_201_CREATED)
async def create_firm(req: dict, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""创建投资机构画像。"""
firm = FirmProfile(
tenant_id=user.tenant_id,
name=req.get("name"),
focus_areas=req.get("focus_areas"),
stage_preference=req.get("stage_preference"),
description=req.get("description"),
)
db.add(firm)
await db.flush()
return success(data={"id": str(firm.id)}, message="创建成功")
@router.get("/funds", response_model=ApiResponse[list])
async def list_funds(firm_id: str | None = Query(default=None), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取基金列表。"""
query = select(FundProfile)
if firm_id:
query = query.where(FundProfile.firm_id == firm_id)
result = await db.execute(query)
items = result.scalars().all()
return success(data=[
{"id": str(i.id), "firm_id": str(i.firm_id), "name": i.name, "fund_size": i.fund_size, "vintage_year": i.vintage_year, "strategy": i.strategy}
for i in items
])
@router.get("/managers", response_model=ApiResponse[list])
async def list_managers(firm_id: str | None = Query(default=None), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""获取投资经理列表。"""
query = select(ManagerProfile)
if firm_id:
query = query.where(ManagerProfile.firm_id == firm_id)
result = await db.execute(query)
items = result.scalars().all()
return success(data=[
{"id": str(i.id), "firm_id": str(i.firm_id), "name": i.name, "focus_areas": i.focus_areas, "portfolio_count": i.portfolio_count}
for i in items
])
+415
View File
@@ -0,0 +1,415 @@
"""月报路由:CRUD + 提交 + AI 解析(SSE 流式)。"""
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, 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 (
MonthlyReportCreate,
MonthlyReportListResponse,
MonthlyReportResponse,
MonthlyReportUpdate,
)
from app.services.ai_parser import parse_report
from app.services.health_calculator import calculate_health_score, determine_trend
from app.services.report_tracker import compute_timeliness
from app.services.risk_engine import detect_risks
from app.services.file_parser import parse_file
router = APIRouter(prefix="/reports", tags=["reports"])
async def _get_company_or_404(db: AsyncSession, company_id: str, tenant_id: str) -> Company:
"""验证企业属于当前租户。"""
result = await db.execute(
select(Company).where(Company.id == company_id, Company.tenant_id == tenant_id)
)
company = result.scalar_one_or_none()
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
return company
@router.get("", response_model=ApiResponse[MonthlyReportListResponse])
async def list_reports(
company_id: str | None = Query(default=None, description="按企业筛选"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取月报列表。"""
# 获取当前租户的企业 ID 集合
company_query = select(Company.id).where(Company.tenant_id == user.tenant_id)
if company_id:
company_query = company_query.where(Company.id == company_id)
query = select(MonthlyReport).where(
MonthlyReport.company_id.in_(company_query)
)
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar_one()
offset = (page - 1) * page_size
query = query.order_by(MonthlyReport.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
reports = result.scalars().all()
return success(
data=MonthlyReportListResponse(
items=[MonthlyReportResponse.model_validate(r, from_attributes=True) for r in reports],
total=total,
page=page,
page_size=page_size,
)
)
@router.get("/{report_id}", response_model=ApiResponse[MonthlyReportResponse])
async def get_report(
report_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取月报详情。"""
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="月报不存在")
return success(data=MonthlyReportResponse.model_validate(report, from_attributes=True))
@router.post("", response_model=ApiResponse[MonthlyReportResponse], status_code=status.HTTP_201_CREATED)
async def create_report(
req: MonthlyReportCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""创建月报。"""
await _get_company_or_404(db, req.company_id, user.tenant_id)
# 检查同企业同年月是否已有月报
existing = await db.execute(
select(MonthlyReport).where(
MonthlyReport.company_id == req.company_id,
MonthlyReport.period_year == req.period_year,
MonthlyReport.period_month == req.period_month,
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"{req.period_year}{req.period_month}月月报已存在",
)
report = MonthlyReport(
company_id=req.company_id,
period_year=req.period_year,
period_month=req.period_month,
raw_content=req.raw_content,
status="draft",
)
db.add(report)
await db.flush()
return success(
data=MonthlyReportResponse.model_validate(report, from_attributes=True),
message="创建成功",
)
@router.put("/{report_id}", response_model=ApiResponse[MonthlyReportResponse])
async def update_report(
report_id: str,
req: MonthlyReportUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""更新月报。"""
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="月报不存在")
update_data = req.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(report, key, value)
await db.flush()
return success(
data=MonthlyReportResponse.model_validate(report, from_attributes=True),
message="更新成功",
)
@router.post("/{report_id}/submit", response_model=ApiResponse[MonthlyReportResponse])
async def submit_report(
report_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""提交月报(状态从 draft → submitted)。"""
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="月报不存在")
if report.status != "draft":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"当前状态为 {report.status},无法提交",
)
report.status = "submitted"
report.submitted_by = user.id
report.submitted_at = datetime.now(timezone.utc)
await db.flush()
return success(
data=MonthlyReportResponse.model_validate(report, from_attributes=True),
message="提交成功",
)
@router.delete("/{report_id}", response_model=ApiResponse[None])
async def delete_report(
report_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""删除月报。"""
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="月报不存在")
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",
},
)
@router.get("/timeliness", response_model=ApiResponse[list])
async def get_timeliness(
company_id: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取月报提交及时性和数据质量评分。"""
items = await compute_timeliness(db, user.tenant_id, company_id)
return success(data=items)
@router.post("/upload", response_model=ApiResponse[dict])
async def upload_report_file(
file: UploadFile = File(...),
user: User = Depends(get_current_user),
):
"""上传月报文件 — 自动解析提取文本内容。
支持 .xlsx、.pdf、.txt、.md、.csv 格式。
"""
if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空")
allowed_extensions = {".xlsx", ".xls", ".pdf", ".txt", ".md", ".csv"}
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
if f".{ext}" not in allowed_extensions:
raise HTTPException(status_code=400, detail=f"不支持的文件格式: .{ext}")
content = await file.read()
if len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
extracted_text = await parse_file(content, file.filename)
return success(data={
"filename": file.filename,
"file_type": ext,
"extracted_text": extracted_text[:10000],
"char_count": len(extracted_text),
})

Some files were not shown because too many files have changed in this diff Show More