Compare commits
86 Commits
2eaf8e9deb
...
ad93c8c31e
| Author | SHA1 | Date |
|---|---|---|
|
|
ad93c8c31e | |
|
|
3ace44a324 | |
|
|
6a3eac64a3 | |
|
|
f33073d1a2 | |
|
|
d3ef506c97 | |
|
|
42944928a1 | |
|
|
edf18cb15f | |
|
|
d1c4b71376 | |
|
|
f48a4d0381 | |
|
|
37056f8fac | |
|
|
169bda214f | |
|
|
da74902c88 | |
|
|
9de48aae3b | |
|
|
2943e046e9 | |
|
|
0d62b8c0c1 | |
|
|
61def5280b | |
|
|
4a687f1e36 | |
|
|
0bd9c1ddf7 | |
|
|
fc888cbb8d | |
|
|
e8bb125608 | |
|
|
37bf7e9b93 | |
|
|
e65a8b1a18 | |
|
|
77fa288a81 | |
|
|
4a74eea75d | |
|
|
8d60b18214 | |
|
|
86c6ec79d0 | |
|
|
5b2add2924 | |
|
|
3609bfe687 | |
|
|
3f17f1d52d | |
|
|
6bfc3795ae | |
|
|
98e2c759aa | |
|
|
bfd35a3320 | |
|
|
e4edfac782 | |
|
|
96b045fc66 | |
|
|
785dacb75f | |
|
|
69a04f4d3f | |
|
|
8cc0cd9e78 | |
|
|
ea8f9d082e | |
|
|
5db6f71f68 | |
|
|
8594d8eef7 | |
|
|
5fd4e6e890 | |
|
|
675485ad0f | |
|
|
caf5c12271 | |
|
|
66ec4c7d1c | |
|
|
1d213baf4b | |
|
|
702545957e | |
|
|
d2c92a980f | |
|
|
e1ecba1f39 | |
|
|
13d4b2562d | |
|
|
32af4226f2 | |
|
|
fe7807bbd1 | |
|
|
875b9292e9 | |
|
|
b9b4b5a306 | |
|
|
199be13681 | |
|
|
5a86007e4c | |
|
|
9ba9876811 | |
|
|
e90adbc2eb | |
|
|
8f1edbf423 | |
|
|
95892d25ec | |
|
|
6c0f6dac53 | |
|
|
a7008bb3cc | |
|
|
2956906f19 | |
|
|
4b2193e828 | |
|
|
f95bd15ff3 | |
|
|
d5e76d67d2 | |
|
|
1d34e32788 | |
|
|
b87951935a | |
|
|
f0708fa02b | |
|
|
e04ea4d1ee | |
|
|
b52428f79e | |
|
|
7f9571c3fa | |
|
|
16f30bc2c6 | |
|
|
b0a8c6edb9 | |
|
|
fd94057e2b | |
|
|
b125424e8f | |
|
|
319a422362 | |
|
|
e6ff0d5e54 | |
|
|
3376c224e3 | |
|
|
0e252281cd | |
|
|
809ff9428c | |
|
|
5194bf8c99 | |
|
|
de57318632 | |
|
|
656cefa668 | |
|
|
689cf94d4b | |
|
|
7364c143fb | |
|
|
0981acb06c |
17
.env.example
17
.env.example
|
|
@ -11,12 +11,13 @@ DB_PORT=5435
|
|||
REDIS_PORT=6379
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=5174
|
||||
FRONTEND_PROD_PORT=5175
|
||||
PMFLOW_EXPECTED_DB_DIALECT=postgresql
|
||||
|
||||
# Backend Runtime
|
||||
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174
|
||||
LLM_PROVIDER=glm
|
||||
LLM_FALLBACK_PROVIDER=glm
|
||||
LLM_PROVIDER=dashscope
|
||||
LLM_FALLBACK_PROVIDER=dashscope
|
||||
CONTROL_PLANE_ROUTING_ENABLED=true
|
||||
CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json
|
||||
CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
||||
|
|
@ -29,6 +30,7 @@ LLM_REQUEST_TIMEOUT=90
|
|||
# 模型默认值统一由 backend/.control_plane/pmflow-routing-rules.json 管理,不再依赖 LLM_ROUTING_* / *_MODEL 环境变量
|
||||
DASHSCOPE_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
ZHIPU_API_KEY=
|
||||
DEEPSEEK_API_KEY=
|
||||
OLLAMA_HOST=http://host.docker.internal:11434
|
||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||
|
|
@ -43,3 +45,14 @@ RAG_DEEP_ANSWER_MAX_TOKENS=1400
|
|||
# Feishu long connection retry policy (1s -> 2s -> 4s, max 3 attempts)
|
||||
FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1
|
||||
FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3
|
||||
|
||||
# GitHub App platform connection.
|
||||
# SaaS/hosted PMFlow configures this once. End users only install the app and select repositories in GitHub.
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_SLUG=
|
||||
GITHUB_APP_INSTALL_URL=
|
||||
GITHUB_APP_CALLBACK_URL=
|
||||
GITHUB_APP_WEBHOOK_SECRET=
|
||||
# Prefer a secret-mounted PEM file in production. Host path: ./secrets/github-app-private-key.pem
|
||||
GITHUB_APP_PRIVATE_KEY_PATH=/app/secrets/github-app-private-key.pem
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
|
|
|||
|
|
@ -68,6 +68,19 @@ Pixelle-Video/
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
secrets/
|
||||
|
||||
# Playwright browser profiles
|
||||
frontend/.playwright-profiles/
|
||||
.playwright-cli/
|
||||
frontend/.playwright-cli/
|
||||
frontend/output/
|
||||
output/
|
||||
.tmp-feishu-*.png
|
||||
task_instance_*.xlsx
|
||||
yesterday_tasks_*.csv
|
||||
yesterday_tasks_*.xlsx
|
||||
工单数据_*.xlsx
|
||||
工单全字段报表_*.xlsx
|
||||
tmp_short_confirm_redesign_summary.html
|
||||
ai.rtf
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"version": "v2",
|
||||
"updated_at": "2026-03-05T00:00:00Z",
|
||||
"source_of_truth": "openclaw",
|
||||
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
|
||||
"rules": [
|
||||
{
|
||||
"name": "engine_low_execute_glm_flash",
|
||||
"enabled": true,
|
||||
"priority": 300,
|
||||
"tier": "low",
|
||||
"provider": "openai",
|
||||
"model": "zai/glm-4-flash",
|
||||
"match_any": [
|
||||
"feishu_level_classification",
|
||||
"feishu_signal_extraction",
|
||||
"rag_intent_classification",
|
||||
"classify_intent",
|
||||
"feishu_humanized_reply",
|
||||
"extract_context_insights"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_high_reasoning_minimax",
|
||||
"enabled": true,
|
||||
"priority": 220,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "minimax/MiniMax-M2.5",
|
||||
"match_any": [
|
||||
"adaptive_rag_answer",
|
||||
"feishu_project_prefill",
|
||||
"feishu_task_orchestration",
|
||||
"generate_task_breakdown",
|
||||
"generate_charter_from_signal",
|
||||
"decompose_tasks_from_charter",
|
||||
"recommend_assignees_for_task",
|
||||
"evaluate_model_requirement",
|
||||
"calculate_alignment_score",
|
||||
"detect_cognitive_conflicts",
|
||||
"rag_db_project_semantic_retrieval",
|
||||
"workload_optimize_prompt",
|
||||
"analyze_cost_roi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_high_extended_reasoning",
|
||||
"enabled": true,
|
||||
"priority": 200,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "minimax/MiniMax-M2.5",
|
||||
"match_any": [
|
||||
"analyze_food_calories",
|
||||
"feishu_new_project_brief",
|
||||
"feishu_project_signal_classification",
|
||||
"sstc_ai_breakdown",
|
||||
"sstc_charter_localize_zh",
|
||||
"sstc_charter_prefill",
|
||||
"skill_score_update",
|
||||
"test_engine_connection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_safety_fallback",
|
||||
"enabled": true,
|
||||
"priority": 50,
|
||||
"tier": "safety",
|
||||
"provider": "openai",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"match_all": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 227 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 221 KiB |
71
AGENTS.md
71
AGENTS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
你是一位资深的全栈工程师,精通 Python 3.11+、FastAPI、SQLAlchemy 2.0、LangChain、React 18、TypeScript、shadcn/ui、Tailwind CSS。
|
||||
|
||||
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目管理操作系统,专为 10-50 人 AI 开发团队打造。
|
||||
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目执行操作系统,首版面向个人开发者、一人公司、半技术产品创作者与 1-10 人 AI 产品小团队,采用 `solo-first, team-ready` 方向。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -97,7 +97,33 @@ frontend/ # React 前端
|
|||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||
- MCP 模板接入中心只允许保存模板参数、命令预览、runtime probe 与“本机已执行”确认状态;后端 `backend_direct` 模板可由 PMFlow backend 直接校验/调用(包括远程 `streamable_http` MCP 与内建 `backend_adapter` runtime),本地 `stdio` 模板只能走 PMFlow Local Bridge 或兼容 MCP host;禁止从服务端直接写宿主机 `~/.codex/config.toml`、禁止替客户执行本机命令,GitHub 类模板只允许保存 env var 名称或占位符,不得落明文 token。
|
||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||
- 前端身份边界必须与真实写接口一致:`/space`、真实 `/project-launch`、`/me`、项目主链 standalone 与空间管理/平台运营入口必须登录;客户空间管理入口看当前 workspace role,平台运营入口看 `platform_context.capabilities`,禁止把客户空间 `OWNER/REVIEWER` 当成 PMFlow 平台管理员;`/project-launch?demo=1` 或 `?dataSource=demo` 是唯一匿名启动台演示入口,且只能使用本地 mock/demo,不得调用真实写接口。
|
||||
- 真实 `/project-launch` 的 AI 草案生成优先走任务化链路:`POST /pmflow-launch/draft-jobs` 创建生成任务,前端轮询 `GET /pmflow-launch/draft-jobs/{job_id}`;“补充一句”必须提交用户真实补充并生成新 revision,旧运行中 job 只能标记为 `superseded/cancelled` 或忽略其结果,禁止用前端硬编码补充、UI 暂停动画或旧同步请求冒充可暂停/续写的 AI 调用。
|
||||
- Project Launch、需求结构化、任务拆解等保护型结构化 AI 场景的超时必须来自显式场景运行策略并写入 `routing_meta`,包括 `scene_timeout_policy_id / input_bucket / provider_timeout_seconds / job_timeout_seconds / large_input_policy`;底层 transport 只消费该合同,不得靠散落常量或函数名私自决定。`structured_timeout` 必须作为模型超时单独归因和展示,禁止与 JSON 无效、空响应混成笼统“结构化失败”。
|
||||
- Project Launch 草案生成与项目主链“发送并提取”变更抽取的 `max_tokens / repair_max_tokens / temperature / response_mode / foreground_wait_seconds / provider_timeout_seconds / job_timeout_seconds / max_retries / large_input_policy` 必须来自 Function Runtime Profile active 发布版本;draft 只能在平台 AI Ops 的业务场景路由里编辑、校验、真实 smoke 和发布,运行时不得读取未发布 draft,也不得把散落常量作为唯一真源。`provider_timeout_seconds` 必须控制上游 HTTP client timeout,`job_timeout_seconds` 必须控制外层任务/调用超时失败,`max_retries` 必须控制 provider transient retry budget,`large_input_policy=preprocess_then_generate` 必须在 large 输入桶进入有界材料压缩后再渲染 prompt,`foreground_wait_seconds` 必须返回给前端作为前台等待窗口。
|
||||
- 用户可见错误必须使用产品语义分类,禁止直接展示 Axios/FastAPI 原始错误(如 `Request failed with status code 401`、`Could not validate credentials`、traceback);401/无 token 必须表达为登录/会话问题,pending intent 只能恢复输入,禁止登录后自动重放真实写请求。
|
||||
- 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。
|
||||
- PMFlow AI 用户侧入口必须遵守“双脑结构”:Space AI 是跨项目注意力、想法孵化、待确认聚合、AI 进行中监控和入口路由的 Operating Partner;项目级 AI 副驾是绑定 ProjectTruthState、ChangeSet、Evidence、ExecutionContext 与 ReleaseSignals 的上下文操作副驾。禁止把两者做成无对象锚点的泛聊天;Space AI 不得直接写 Project Memory、提交 ChangeSet 或发起 Execution Bridge handoff;项目副驾不得做跨项目经营排序、平台 provider/model/prompt 诊断或绕过确认链。
|
||||
- Space AI Concierge / 用户侧 PMFlow AI 必须把用户原始问题与结构化 `surface_context` 分开传递,只回答 PMFlow 产品/操作知识、当前 tenant/project/space 状态、下一步指导与带来源的外部公开信息;系统代码、数据库表、prompt key、trace、函数名、模型供应商、内部路由、技术架构与架构实现必须被 `PMFlowAIDisclosureState -> protected_internal_refusal.v1 -> AnswerVerifier` 主链阻断或清洗,返回 payload 禁止暴露 raw source label、tool trace 或底层异常,内部诊断只能走显式 `/platform/*` 入口。
|
||||
- `/space/pmflow-ai` 与兼容 `/space/ai-concierge/ask` 的用户侧主控必须先进入 `PMFlowAgentRuntimeService.run_space_turn()`,该服务只负责创建 durable `pmflow_agent_runs` 并委托 `PMFlowAIAgentRunService` 执行 `LLM Step Planner -> typed AgentToolRegistry tool -> Observation Ledger -> State Critic -> Terminal Answer State -> PMFlowAIAgentAnswerRuntime`。`pmflow_agent_runtime/loop.py`、legacy `AnswerArbitrator/NaturalAnswerComposer`、旧 Turn Runtime 和 CustomerAnswerPresentationService 只能作为 compatibility/tool/renderer 资产,禁止恢复为产品主控或最终答案来源。
|
||||
- 旧 `PMFlowAIConversationOrchestrator / SpaceAIExecutionContract / SpaceAITaskGraphService / pmflow_ai_runtime_kernel` 已从 Space 用户侧代码中删除;`PROMPT_PMFLOW_AI_ROUTE_PLANNER` 仅保留为 legacy/deprecated 资产,不得恢复为主控,也不得再拍板 `source_strategy / answer_contract / allowed_executors / answer_obligations` 或分派 analyzer 早返回。
|
||||
- `/space/ai-concierge/ask` 的 API 层只加载 context、结构化 `interaction_submission`、持久化和返回,不得绕过 Agent OS 主控。`interaction_submission` 只能折叠为 raw question、answers 和 supplement;`intent_hint / evidence_mode / primary_subject` 旧推导字段已删除,禁止从按钮、选项、question id 或关键词恢复。`route_plan / answer_contract / source_strategy / allowed_executors` 在 Space 用户侧只能作为兼容 payload 或离线解释字段,禁止再决定 action、来源、追问或最终 answer shape。
|
||||
- 项目查找、项目状态、风险、容量、MVP 等能力不再靠问句关键词或固定 `answer_contract` 进入专用链路;必须先由 TurnState 判断目标、对象锚点和证据缺口,再调用 `PMFlowAIEvidenceToolRegistry` 的白名单只读 evidence tool。普通项目清单/项目数量等 surface 可验证事实也只能作为 `inspect_surface_context` 工具结果进入 TurnState。Packet 6 后工具层只返回事实、来源、置信度、目标相关性和剩余缺口,不得生成 implication、judgement 或最终答案;`PMFlowAIEvidenceAbsorber` 是唯一把 evidence 转成 `fact / source / implication / relevance_to_goal / confidence`、`remaining_gaps` 和 `JudgementState` 的层;`CustomerAnswerPresentationService` 只消费 final TurnState/CustomerEvidencePacket 的 `answer_shape` 做 renderer,不得再按 quick intent、旧 route contract、packet 名称或 answer_type 场景枚举重判展示形态。旧项目查找/通用合成/展示模板早返回服务不得恢复。
|
||||
- 当前项目 `status_answer / execution_guidance` 必须先经过项目执行态覆盖门禁:`inspect_project_status` 必须读取 PMFlow OS truth-state 与 execution-summary,并同步输出 `ProjectOperatingEvidencePacket`,覆盖指挥台、项目工作台、决策与待确认、开发执行、测试质量、发布增长、团队层、项目记忆、风险阻塞和最近动态这些 PMFlow OS 全流程 facet;Packet R 后这些 facet 必须由独立 reader 消费对应 PMFlow OS 投影,禁止退回单函数拼字段或只读项目 `active`、项目数量、可见卡片状态生成推进建议。Packet S 后 `PMFlowAIEvidenceCoverageGate` 只能消费 `TurnState.evidence_state.structured_evidence.project_operating_evidence_packet(s)` 的 facet coverage profile,禁止再通过中文 finding 文案、关键词或固定短句判断 coverage。覆盖不足时必须先触发低风险只读 probe,不得直接合成最终答案或把系统缺口转成用户追问。Packet T 后最终合成必须生成并消费 `project_operating_answer_verification.v1`:`safe_next_actions` 才能进入正文/customer packet/canvas 的可推进动作,`blocked_actions` 只能进入不可直接做边界,`missing_or_partial_facets` 必须进入证据边界;禁止让模型或展示层绕过该 verification profile 自由生成完整推进方案。
|
||||
- `/space/ai-concierge/ask` 的 ActionSelector 只能根据 `PMFlowAITurnState.evidence_state.evidence_gaps`、`sufficiency_state` 和白名单只读 action 选择下一步;禁止按自然语言关键词、`looks_like_*`、业务枚举、旧 `answer_contract`、旧 `task_type` 或 executor 列表做运行时拍板。项目查找、项目状态、外部查询、MVP、风险、容量等能力只能通过 `PMFlowAIEvidenceToolRegistry` 包装成 evidence tool,不能早返回抢主控。
|
||||
- 项目状态、风险、容量和 MVP 工具仍必须遵守只读边界:只返回证据、依据、缺口和建议,不写 Project Memory、不提交 ChangeSet、不写 review decision、不发起执行、不发布、不自动派任务、不写 capacity override;对象范围由 TurnState 的 explicit/current page/reply/card/active workspace strong focus 决定,visible context 只能作为候选或证据范围。
|
||||
- 新增任何用户可见 PMFlow 能力时,如果用户会问“这个能力现在怎么样 / 下一步怎么办 / 能不能让 AI 处理 / 风险是什么”,必须优先同步加入 Agent OS typed tool 合同、planner/critic 行为、observation/artifact/verification 投影、Terminal Answer State/Answer Runtime、runtime golden behavior case、回归测试和文档;兼容 Turn Runtime 只作为旧链路适配。不得只新增 `goal_kind / answer_contract / analysis_task / business_frame / evidence_plan`、executor 枚举、关键词路由或 specialized analyzer;没有专用 tool 时也必须通过通用 evidence tool 返回有边界的判断、依据、缺口和下一步。
|
||||
- 用户侧 PMFlow AI 的回答生成和最终返回都必须经过 `Answer Runtime -> Answer Quality Gate -> CustomerAnswerPresentationService -> CustomerEvidencePacket -> Customer Payload Gate` 合同:最终合成前先形成 `PMFlowAIAnswerObjective / PMFlowAIAnswerPlan / PMFlowAIAnswerDraft` 与客户安全 `answer_runtime_packet`,再按 `strategic_judgement / status_answer / clarification / execution_guidance / fact_list / composite_answer / general_answer` 这些表达形态渲染。Renderer 只能作为 draft source,不做目标识别、来源选择、对象绑定、追问仲裁或 answer_type 重判;静态产品合同和保护拒答合同只能提供 `required_claims / customer_safe_capabilities / source_summary / limitations`,不得保存整段终局回答或要求全文相等;Answer Quality Gate 是唯一答案质量出口,必须把空 draft、内部运行时术语泄漏、计划语句回显、静态合同材料缺失写成 `PMFlowAIAnswerQualityFinding`,并对重写结果二次复检,仍失败时 fail-closed,禁止恢复 `_sanitize_state_answer` 这类 renderer 局部兜底;项目运行态 verification 等后处理约束必须先合入 draft 再过 gate,写入 `answer / customer_evidence_packet / answer_canvas` 的文本也必须先客户安全投影,禁止后处理绕过 gate;`CustomerAnswerPresentationService` 只能清洗、裁剪和承接 runtime 已生成的自然答案,不得在 `answer_canvas.direct_answer` 或 packet fallback 中二次生成 `结论/判断依据/综合判断/可执行建议/依据` 这类固定段落或 answer_type 前缀;Customer Payload Gate 必须移除 raw `answer_runtime_packet / pmflow_ai_turn_state / evidence_findings / route_plan / trace / tool_trace / debug`,历史归档也必须使用同一份 sanitized customer payload;Composer/Verifier 只能消费 final TurnState、Answer Runtime packet 和 customer-safe packet 的 `display_label/display_summary/business_values`,禁止把 `System Snapshot / User Snapshot / Project database / raw JSON / 字段名 / trace / prompt key` 直接喂给用户答案表达层,也禁止靠新增场景关键词或固定模板解决回答重复问题。
|
||||
- 用户侧 PMFlow AI 的终局表达材料必须先经过 `PMFlowAITerminalAnswerState`:所有可说内容进入 `Claim Ledger`,所有不可说/不可确认/不可执行边界进入 `Boundary Ledger`,所有尝试进入 `Attempt Ledger`,表达策略进入 `AnswerIntentPlan`。Verifier 的 `reason/internal_diagnostic` 只能作为内部诊断,不得进入用户 answer、limitations、CustomerEvidencePacket 或客户可见 terminal state;客户可见只允许消费 `customer_safe_summary/customer_safe_effect`。`AnswerIntentPlan` 只能从 Claim/Boundary Ledger 选择 lead claim/boundary,并输出 `answer_moves / composition_guidance / anti_template_rules / detail_level / structure_preference` 这类表达策略;它不得生成最终正文、不得按业务场景或 failure_type 选择固定 presenter、不得读用户问句关键词做路由,也不得强制固定“判断依据/证据边界/下一步”三段式。最终自然表达必须经通用 terminal customer answer composer 和 critic:composer 只消费 terminal ledgers + AnswerIntentPlan,critic 只检查空答、内部运行时术语、固定标题模板、重复句和 lead claim/boundary 丢失,不得新增场景化 Customer Failure Presenter。Quality fail-closed 与 state fallback 也必须重建干净 terminal state 再表达,禁止复用被失败 draft 污染的 `answer_draft_claim`,禁止返回固定“质量检查未通过/旧链路模板/项目运行证据不足”句。禁止把所有 blocked/fail-closed 默认解释成项目运行证据不足,禁止按 failure_type 写固定 presenter,禁止从 raw runtime step 或 verifier reason 直接生成用户正文。
|
||||
- 用户侧 PMFlow AI 的 verifier failure 必须输出 `verification_delta` 并声明 `retry_would_change_state`;runtime 只有在 retry 会改变 TurnState 或能执行新的只读 probe/read 时才允许继续重试。`next_action_hint=synthesize_answer` 不得单独制造重复合成循环,Reviser 也不得在 retry=false 时创建 system action gap。没有 state delta 的失败必须进入 terminal-state blocked payload,由 Claim/Boundary Ledger 提供客户安全材料。
|
||||
- 用户侧 PMFlow AI 回复必须同时返回客户安全的 `customer_interaction_context_packet` 与 `answer_process_packet`:前者只暴露 `visible_objects/current_focus_object` 这类业务对象锚点,用于“这个项目/刚才那个/它”的续问归位;后者只描述本轮真实执行过的产品化步骤,供前端逐步展示,并可带 `duration_ms / total_duration_ms` 说明真实耗时归因。禁止前端用固定五步、固定 active index 或后台 trace 冒充思考过程,禁止在这些 packet 中暴露表名、字段名、prompt key、函数名、trace 或模型供应商。
|
||||
- Packet U/V 后,展示层不得再根据 `answer_type / route_display / verification.ok` 自行生成“识别问题、读取证据、表达判断、检查展示”等固定过程步骤;`CustomerAnswerPresentationService.build_answer_process_packet()` 只能保留显式传入的 runtime/process steps,Space 前端只展示后端返回的真实 steps。来源展示也必须 fail-closed:后端不得在无来源时补 `PMFlow 上下文`,前端不得在 `sources` 为空时补 `个人空间/PMFlow 上下文`。
|
||||
- Space AI 多轮续问对象归位必须以 Session Object Ledger 为真源:每轮只从真实返回 payload 的客户安全对象生成 `current_focus_object / last_shown_objects / recent_objects`,写入 `qa_session_states.context_capsules`;下一轮只有弱指代且 TurnState 目标需要单对象 inspection/execution 时,才允许用 ledger 绑定 `primary_object(binding_strength=session_object_ledger)`。禁止用 transcript 词面、项目名关键词、唯一可见项目或前端按钮文案恢复对象;有可执行系统证据缺口时必须先低风险 read/probe,不能把系统缺口 label 当成用户追问。
|
||||
- Session Object Ledger 必须受 `object_binding_decision` 和 topic switch gate 约束:当 TurnState 判断本轮是新项目、新想法、新方向、无对象或拒绝上一轮焦点时,必须挂起旧 `current_focus_object`,禁止用 ledger 触发 `inspect_project_status / inspect_internal_projects`。工具证据进入 `known_facts` 前必须经过目标相关性过滤;无关项目证据只能进入缺口/审计,不得作为新想法判断依据。用户答案出口必须清除 `evidence_findings / TurnState / problem_frame` 等内部运行时术语。
|
||||
- Turn Runtime 不得因为 `can_answer_now=true` 跳过未执行的系统读证据动作;只要 TurnState 仍有可执行的非用户 `system_evidence_gap/freshness_gap`,必须先执行对应 read/probe,再合成或显式说明剩余缺口。新项目/新想法/开放方向必须用 `PMFlowAIThreadWorkspace` + `ResearchWorkspace.collected_inputs` 持久化线程 slot,后续轮次合并 `PMFlowAITurnDelta` 继续推进,禁止靠 transcript、关键词、按钮文案或场景枚举恢复上下文。
|
||||
- 开放方向/新想法在 `can_answer_with_assumptions=true` 且无强项目绑定时,最终回答必须消费 TurnState 与 workspace slot 给出受控初判、切口、风险、缺口和下一步,禁止因没有 external findings 直接退成“证据不足不能判断”。需要用户补充时,`interaction_request` 必须批量消费 workspace pending questions 与 user-actionable gaps 生成 stepper,能一次问完的关键缺口不得拆成多轮单点模板追问。
|
||||
- PMFlow ChangeSet 写入边界:`accepted / edited / deferred` 只表示候选项已被人工处理但尚未写入 Project Memory;只要 ChangeSet 未 committed 且 ChangeItem 未 committed/skipped,就必须允许继续 accept/edit/defer。真正锁定边界是用户明确执行 ChangeSet commit / 确认写入项目记忆之后。
|
||||
- PMFlow 审查与执行边界:团队层只做责任路由、角色收件箱、团队动态、审查状态、阻塞解释和跳转,不直接执行 ChangeSet commit 或写 Project Memory;开发执行只能针对 Project Memory commit 后形成的正式 Task 发起 Execution Bridge handoff,禁止从 `pending_review` ChangeItem 或候选 Task 直接分派人/agent 执行。
|
||||
- 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。
|
||||
- `query.answer` 路由与外部查询规划不得在运行时代码里维护 `_INTERNAL_ROUTE_OBJECTS / _EXTERNAL_ROUTE_OBJECTS / _SEMANTIC_QUERY_OBJECT_TYPES` 一类对象词表,也不得新增 `*_probe_is_strong / *_route_probe / query_kind(weather/news/company/market/model)` 这类二次语义拍板。`query_profile / source_policy_hint / SemanticDecision` 只能作为上游结构化输入;若证据不足,只能显式返回 `uncertain/clarify`,禁止在执行层靠对象集合、`subject_focus`、`needs_freshness`、`referenced_objects` 再次裁决 internal vs external。
|
||||
- `llm_service` 运行时必须显式传入 `routing_meta`;禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。
|
||||
|
|
@ -111,7 +137,9 @@ frontend/ # React 前端
|
|||
- 外部 freshness 问答的 follow-up 若锚定上一轮“未接入实时外部数据/无法确认最新结论”的 QA 回复,默认继续留在 `query.answer` 聊天链并先尝试实时外部检索;只有用户显式点明系统/项目/配置等内部对象并提出接入动作时,才允许落入内部接入/入库流程。
|
||||
- non-plan `query.answer` 必须先经过 `Query Route Gateway` 产出 `route_mode / decision_origin / reply_contract / probe_snapshot`,再进入 AdaptiveRAG;回复正文必须遵守显式来源合同(`内部知识库查询:` / `外部实时查询:`),`uncertain` 只允许 `external-first` 或澄清,禁止默认混合答案。
|
||||
- `route_mode=external` 后不得再直接把 DuckDuckGo HTML 抓取当主执行层;必须统一进入 `External Query Planner -> External Query Gateway -> External Provider Registry -> MCP adapter 优先 -> bounded fallback live search -> external answer verifier` 主链。external 来源过滤必须 fail-closed,禁止把 `Feishu history / doc / pmflow` 这类内部来源混回 `外部实时查询` 的来源摘要。
|
||||
- `freshness_required=true` 的行业/市场/趋势类外部查询必须执行近年证据门禁:planner 要前置当前年/上一年查询,live search 不能第一条旧结果即停止,gateway verifier 必须拒绝只有过期年份材料的证据包,禁止把 2024 白皮书类旧资料冒充 2026 当前趋势。
|
||||
- `query.answer` 的客户可见回复必须再经过 `Answer Presentation Policy`:客户视图只允许看到中文化 `display_label/display_summary`,禁止直接暴露 `raw_source_label`、`Project database / Semantic facts / Knowledge Graph / Feishu history` 等机器来源或内部术语。prompt context 也只能喂展示字段,不能再把原始英文来源标签直接喂给模型。
|
||||
- `PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT / PROMPT_CUSTOMER_ANSWER_COMPOSER / PROMPT_CUSTOMER_ANSWER_VERIFIER / PROMPT_RAG_QUERY_SEMANTICS / PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS / PROMPT_RAG_PENDING_CLARIFICATION_SELECTION_SEMANTICS` 必须运行时读取 Prompt 运营中心 active release,并在 `ai_call_logs.routing_meta` 写入 `prompt_key / prompt_release_id / prompt_source`;禁止恢复为直接硬编码模板调用且无 prompt 归因。`PROMPT_PMFLOW_AI_ROUTE_PLANNER` 只允许作为 legacy/deprecated prompt key 占位,不得在 Space 用户侧运行时调用。
|
||||
- internal `query.answer` 一旦命中 `route_mode=internal`,必须再走“`Operation Contract -> Target Resolver -> Query DSL Plan -> 白名单受控执行器 -> semantic facts 优先(可用时)-> bounded fallback retrieval -> answer verifier`”链路;禁止让 LLM 自由写 SQL、自由决定 join/表名,或在没有证据时直接给内部结论。
|
||||
- `MountedDraftWorkspace.requirement_card` 是 requirement-plan 唯一业务真源;assistant prose、topic facts、run 文案只可审计,不可反推业务字段。
|
||||
- `project_create.create / followup.confirm` 的任务拆解是从属子步骤:运行时必须先 low-first 结构化生成,必要时升级 high;仍失败则显式记为 `pending`,禁止冒充成功。
|
||||
|
|
@ -134,7 +162,7 @@ frontend/ # React 前端
|
|||
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||
|
||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||
- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
||||
- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证;普通 LLM 调用仍以 `LLM_REQUEST_TIMEOUT` 为默认,已接入 Function Runtime Profile / 场景运行策略的保护型结构化调用以已发布合同里的显式 `provider_timeout_seconds` 为准。
|
||||
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
||||
- 写操作后主动 invalidate,动态层不缓存
|
||||
|
||||
|
|
@ -204,6 +232,45 @@ frontend/ # React 前端
|
|||
- 发生 context 不足时 → 停止并输出已完成部分
|
||||
声明 `[CONTEXT: LIMIT]`,不要截断输出
|
||||
|
||||
## 17. Codex 并行开发治理
|
||||
|
||||
- 多个 Codex 线程并行推进时,可以按 `Main Flow / Space / Account Settings / Shell Integration` 等大模块 lane 长期并行,但实际执行单位必须是 `Packet`,不得只以模糊“大模块”直接开工。
|
||||
- 每个 Packet 开始前必须有任务包,至少声明 `packet_id / module_lane / owner_thread / lane_owner / integration_owner / base_ref / read_scope / write_scope / forbidden_paths / shared_files / cross_lane_inputs / cross_lane_outputs / depends_on / merge_after / verification / docs_to_sync`。
|
||||
- 没有 `write_scope` 的任务不允许并行;`write_scope` 重叠的 Packet 必须串行。
|
||||
- 每轮并行最多一个 `contract / migration / router owner`;其他线程只能消费该 owner 已定义的稳定接口。
|
||||
- 大模块并行时,`Shell / Integration owner` 负责共享路由、导航、Provider、共享 API base、共享文档和最终合并;模块线程只能写自己的 owned files。
|
||||
- `Space` 线程只做个人空间聚合、草案箱、待确认摘要、AI 进行中摘要和跳转,不直接写 Project Memory、ChangeSet commit 或 Execution Bridge 回写。
|
||||
- `Account Settings` 线程只做个人资料、偏好、通知、账户壳和设置入口,不直接改项目主链、权限核心、AI 路由或 Space 聚合。
|
||||
- `Main Flow` 线程继续推进 PMFlow OS 主链,但不得顺手重做 Space 或个人中心。
|
||||
- 并行线程默认使用独立分支 `codex/<packet_id>`,并优先使用独立 git worktree;不得在同一脏工作区里让多个线程直接写同一批文件。
|
||||
- 一个 Packet 对应一个短生命周期分支;Packet 完成、验证通过、文档同步完成后必须形成可审查 commit。复杂 Packet 可在同一分支内做多个逻辑完整 checkpoint commit,但禁止提交半成品、失败验证或越界改动。
|
||||
- Codex 完成 Packet 后必须自行执行 commit gate 判定:满足 `auto_commit` 条件时应自行提交;命中 `hold_no_commit` 时不得提交并报告阻塞;命中 `ask_before_commit` 时先说明事实再等用户拍板。具体判定以 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md` 的 `Packet commit 协议` 为准。
|
||||
- 不允许在旧 Packet 分支上继续做新 Packet;新 Packet 必须从最新 integration base 或明确依赖分支重新开 `codex/<new_packet_id>`。
|
||||
- 合并前必须检查 `git diff --name-only`,确认没有写出 `write_scope`;越界改动必须拆回对应 Packet 或交给 integration owner。
|
||||
- 合并顺序固定为:Contract / migration → Backend service/API → Frontend API/store → UI/page → Docs/test/integration。
|
||||
- 每批并行开发必须指定一个 integration owner,负责共享文件合并、最终验证、文档同步和进度台账回写。
|
||||
|
||||
共享高冲突区默认同一时间只能一个 Packet 写:
|
||||
|
||||
- `backend/app/api/router.py`
|
||||
- `backend/app/models/__init__.py`
|
||||
- `backend/app/schemas/*.py` 中的跨域 schema
|
||||
- `backend/alembic/versions/*`
|
||||
- `frontend/src/router/index.tsx`
|
||||
- `frontend/src/main.tsx`
|
||||
- `frontend/src/api/base.ts`
|
||||
- `frontend/src/lib/api.ts`
|
||||
- `frontend/src/features/pmflow-os/components/PMFlowOsFrame.tsx`
|
||||
- `frontend/src/features/pmflow-os/components/MobileBottomNav.tsx`
|
||||
- `frontend/src/features/pmflow-os/store.tsx`
|
||||
- `CLAUDE.md`
|
||||
- `docs/architecture/CODE_REFERENCE_INDEX.md`
|
||||
- `docs/architecture/ARCHITECTURE_INVENTORY.md`
|
||||
- `docs/architecture/API_CONTRACTS.md`
|
||||
- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`
|
||||
|
||||
若必须多人改共享文件,先由 integration owner 建立接口占位或合同,再让其它线程只改自己 owned 文件,最后由 integration owner 统一合并共享文件。
|
||||
|
||||
|
||||
## 安全约束(必须严格遵守)
|
||||
|
||||
|
|
|
|||
48
USER.md
48
USER.md
|
|
@ -14,6 +14,24 @@
|
|||
- 回答要直接、具体、贴当前对象,优先给证据链、调用链和真实落点;不要写成模板化汇报。
|
||||
- 不确定就明确说不确定,不要假装完整。
|
||||
- 已拍板方案不要反复推荐替代品;配置和偏好尽量收敛,别分散。
|
||||
- 前端默认视觉标准:品牌主色固定为 `#2463EB`;浅色界面默认以白色 + `#2463EB` 为主,不再回到泛紫、泛青的 AI 套板。
|
||||
- 前端默认交互和样式参考 `Linear` 的克制层级与节奏;所有用户可见内容默认使用中文,除非我明确要求保留英文。
|
||||
- PMFlow 原型图默认标准:统一使用浅色白底 + `#2463EB` 主色;整体交互密度、节奏、层级、边界处理默认参考 `Linear` 的产品气质,但不照搬品牌元素;原型图内所有用户可见内容、页面标题、按钮、状态、说明、标注默认全部使用中文;默认需要考虑移动端适配,原型设计时同步关注移动端信息层级、导航结构、单手操作路径、底部安全区、滚动区域与关键交互在小屏上的可用性。
|
||||
- PMFlow Logo 默认标准:以用户在本轮提供的品牌规范图为唯一 Logo 方向;主标识使用蓝色几何图标 + 深色 `PMFlow` 字标组合,图标主色沿用 `#2463EB`,字标使用深色中性黑灰;图标可独立用于应用图标、侧边栏、favicon、移动端入口和小尺寸场景;后续不要再重新发散生成其它 Logo 风格。
|
||||
- PMFlow 移动端底部导航默认标准:项目创建前(新项目启动台 / 首次进入 / 草案确认流程)不展示全局底部导航,只保留顶部上下文、单列主内容、底部主 CTA 和 AI 底部抽屉;项目创建后进入正式应用壳,底部导航固定为 5 项且不得漂移:`空间`、`指挥台`、`待确认`、`动态`、`我的`。`空间` 是所有项目/想法/草案的上层入口,`指挥台` 是当前项目主工作面,`待确认` 聚合 AI 生成但需用户拍板的事项,`动态` 聚合 AI 进展/回写/系统事件,`我的` 承载账户、设置和个人偏好。具体项目内的 `工作台 / 开发执行 / 发布增长 / 项目记忆` 不进入底部导航,默认通过项目内二级入口、分段切换或更多菜单进入。
|
||||
- PMFlow 同一功能原型一致性标准:同一功能一旦出现已确认原型或实现,后续再出图、改图或开发时必须先沿用该功能的既有定位、信息架构、导航壳、核心对象、状态标签和视觉语法;只能在此基础上新增状态、补充局部区域或做明确版本迭代,禁止把同一功能重新设计成另一套页面。若确实需要重构,必须先说明“旧版是什么、新版改什么、迁移影响是什么”,并得到明确确认。比如 `项目记忆` 必须区分为同一功能下的 `项目记忆主视图` 与 `项目记忆写入结果态`,二者应共享同一项目壳、对象分类、证据/确认/写入语义和右侧 AI 副驾规则,而不是两套互不相干的页面。
|
||||
- PMFlow 高保真原型默认不能按“桌面原型画布宽度”锁死主内容区。桌面保真不等于固定画布:必须同时考虑普通桌面、超宽屏和小屏三档。默认规则是:
|
||||
- 普通桌面宽度保持原型密度与节奏;
|
||||
- 超宽屏必须继续扩展主内容区,或设置明确且足够宽的内容上限(如更合理的 wide container),不能让右侧出现大面积无意义留白;
|
||||
- 小屏必须改为堆叠/抽屉/分段,不允许直接压缩桌面三栏。
|
||||
- 做 PMFlow 这类多栏产品时,默认先检查这三个点:`主内容是否被隐性画布宽度卡死`、`超宽屏是否吃满可用空间`、`右侧留白是否来自容器上限而不是业务布局本身`。
|
||||
- 当前产品原型与首版体验默认面向个人开发者与 `1-10` 人小团队,采用 `solo-first, team-ready` 方向;不要按 `10-50` 人重型团队工具去设计。
|
||||
- PMFlow 产品蓝图真源已沉淀到 `docs/PMFLOW_PRODUCT_BLUEPRINT.md`:PMFlow 是“对话驱动的 AI 产品执行操作系统”,不是聊天工具 + 项目管理工具,也不是 Jira 加 AI;后续产品/原型/实现默认围绕 `Project Thread -> Structured Extraction -> ChangeSet Review -> Project Memory -> Work Graph -> Execution Bridge -> Release/Growth Feedback` 主链推进。
|
||||
- 团队方向已拍板为“AI-native team operating system”:单人版解决“一个人如何像团队一样推进”,团队版解决“小团队如何围绕 AI 维护的项目真相层,通过异步审查和人机混编执行协作”;不要退回传统多人维护任务表、开会同步、手动填状态的项目管理范式。
|
||||
- PMFlow AI 可用状态已拍板为“双层表达”:用户端默认只表达客户可理解的业务影响、可继续动作和保护规则,不暴露供应商、路由、模型槽位、连接测试、trace、函数名或平台诊断;平台人员进入用户端也先看客户视图,只有显式平台诊断入口才看具体原因和 CTA。`/platform/*` 是平台 AI Ops 控制面,默认展示供应商、路由、诊断、模型槽位、失败样本和对应处理动作。
|
||||
- PMFlow 产品推进执行标准已沉淀到 `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`:后续每次 PMFlow 产品推进任务完成后,Codex 默认要自己更新该文档的进度台账;新增/修改入口、API、Schema、边界时同步更新 `CLAUDE.md`、`ARCHITECTURE_INVENTORY.md`、`CODE_REFERENCE_INDEX.md`、`API_CONTRACTS.md` 或 `RUNTIME_GUARDRAILS.md`。新开线程继续产品推进时,必须先读 `USER.md`、产品蓝图、执行标准、`CLAUDE.md` 和代码索引,不得从头重想方案。
|
||||
- 原型图与首版视觉交付默认只做浅色主题,不要求同时输出暗色模式;暗色模式属于后续产品实现能力,不作为当前原型标准。
|
||||
- 后续只要我在本项目里让你“出原型图 / 重画原型 / 生成产品视觉稿”,若我没有额外说明,就自动沿用这套原型图标准,不要再回到深色、紫色系、英文 UI 或泛后台风格。
|
||||
|
||||
### 学习与更新方式
|
||||
|
||||
|
|
@ -64,6 +82,7 @@
|
|||
- AI 路由内部主名:`control_plane`
|
||||
- 路由规则真源:`backend/.control_plane/pmflow-routing-rules.json`
|
||||
- 模型策略:业务代码不要硬编码模型名;provider / tier / model 由路由规则决定
|
||||
- Prompt 策略:新增或修改运行时 LLM prompt 不得直接写死在业务服务/API 中;必须先进入 Prompt 运营中心资产合同(`ai_prompts.py` 默认模板 + `ai_prompt_registry.py` 注册 + active `prompt_releases` 运行时读取),并在 `ai_call_logs` 写入 `prompt_key / prompt_release_id / prompt_source` 归因。历史 `needs_migration` 存量只能作为待迁移清单,不得作为新增写法。
|
||||
- 历史路由命名已废弃,不再把旧名字当现行架构,也不再建议回退
|
||||
- 以上栈默认不再主动建议替代方案,除非用户明确要求比较或迁移
|
||||
|
||||
|
|
@ -77,6 +96,34 @@
|
|||
- 遇到链路问题先补 prompt、关键词、正则,而不是先查 routing、context、session、gatekeeper。
|
||||
- 用 fallback、默认句、stub、模板话术冒充成功。
|
||||
- 把本该直答的问题拖进过深推理、官样文章或汇报体输出。
|
||||
- 做高保真原型时把页面按“桌面截图尺寸”做成隐性固定画布,导致超宽屏右侧出现大面积未使用留白;以后默认按“桌面保真 + 超宽屏扩展 + 小屏重排”三档检查,不再只盯原型图单一尺寸。
|
||||
|
||||
## 前端快回路与线程治理
|
||||
|
||||
### 前端任务分级
|
||||
|
||||
- `L1 微调类`:文案、字重、边框、间距、颜色、单元素位置。默认只允许 focused live 页确认或快验证,不默认跑 full build。
|
||||
- `L2 视觉逻辑类`:响应式、跨屏对齐、布局算法、曲线路径、动画节奏。允许 `build + 多视口截图`,但不夹带无关文案和样式修补。
|
||||
- `L3 结构治理类`:拆组件、拆路由、拆包、改 compose、改启动链路。必须单独线程处理,不和单页 comment 修复混做。
|
||||
|
||||
### 线程是否继续的判断规则
|
||||
|
||||
- 同一对象、同一路径、同一验证面,优先留在原线程直接做完。
|
||||
- 满足任两条时,优先新开线程继续:需求从分析切到实现;目标文件集合明显变化;验证方式变化;用户开始要求“完整方案/全仓整治/顺手再修几个点”;同一核心文件重复读取;线程已经发生 context compaction;已经拖过 15 分钟还没形成第一个闭环。
|
||||
- 当前改动已经能独立闭环时,不顺手带 unrelated fix;尤其不要把 `L1 微调` 混进 `L2 视觉逻辑` 或 `L3 结构治理`。
|
||||
|
||||
### 强制止损阈值
|
||||
|
||||
- 同一线程出现 `context_compacted` 后,如果任务仍未闭环,优先建议切新线程;出现两次 compaction,不再继续混合任务。
|
||||
- 从用户发起到首次实际代码编辑超过 `8` 分钟,必须先说明卡点,不继续闷头探索。
|
||||
- 同一核心文件读取超过 `2` 次,必须先总结当前判断,再决定是否继续读。
|
||||
- 同一轮工具调用超过 `20` 次但仍没有形成首个有效闭环,必须收口范围或切线程。
|
||||
|
||||
### 前端验证预算
|
||||
|
||||
- `L1 微调类`:优先 `verify:login-fast` 或 focused screenshot,不默认跑 `npm run build`。
|
||||
- `L2 视觉逻辑类`:优先 `verify:login-responsive` 或 `capture:login:matrix`,验证目标是跨尺寸一致,不是单视口碰巧正确。
|
||||
- `L3 结构治理类`:独立线程里跑 `typecheck + build + focused smoke`,不拿页面 comment 回合承载。
|
||||
|
||||
## 当前知道但暂不处理的项目问题
|
||||
|
||||
|
|
@ -84,3 +131,4 @@
|
|||
- `backend/tests/test_training_pipeline_service.py` 目前仍有历史 SQLite 测试夹具下的 `JSONB` 建表失败问题,这不是本轮改名引入的,需要单独清理测试基座。
|
||||
- `backend/app/schemas/recipe.py` 与 `backend/app/schemas/skill.py` 仍有 Pydantic V2 的 class-based config deprecation 警告。
|
||||
- 当前运行环境里的 `lark_oapi` / `websockets` 仍有上游 deprecation 警告,后续需要统一升级或兼容处理。
|
||||
- Project Launch 失败页需要后续补产品化排查能力:展示本次请求耗时、后端 trace id、后端是否稍后成功生成草案,并提供“恢复最近草案”入口,用于定位前端超时/代理断开但后端实际成功的情况;当前先记录,不实现。
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"model": "deepseek-chat",
|
||||
"api_env": "DEEPSEEK_API_KEY"
|
||||
},
|
||||
"glm": {
|
||||
"dashscope": {
|
||||
"name": "Qwen (DashScope)",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"model": "qwen-plus",
|
||||
|
|
@ -22,6 +22,12 @@
|
|||
"model": "GLM-5",
|
||||
"api_env": "OPENAI_API_KEY"
|
||||
},
|
||||
"zhipu": {
|
||||
"name": "Zhipu BigModel",
|
||||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"model": "glm-5.1",
|
||||
"api_env": "ZHIPU_API_KEY"
|
||||
},
|
||||
"ollama": {
|
||||
"name": "Ollama",
|
||||
"base_url": "http://127.0.0.1:11434",
|
||||
|
|
@ -31,7 +37,7 @@
|
|||
},
|
||||
"tier_defaults": {
|
||||
"low": {
|
||||
"provider": "glm",
|
||||
"provider": "dashscope",
|
||||
"model": "qwen-plus",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_env": "DASHSCOPE_API_KEY"
|
||||
|
|
@ -54,12 +60,23 @@
|
|||
"api_env": "OPENAI_API_KEY",
|
||||
"function_exact": "classify_intent_complex"
|
||||
},
|
||||
{
|
||||
"name": "engine_low_pmflow_ai_operating_mission",
|
||||
"enabled": true,
|
||||
"priority": 312,
|
||||
"tier": "low",
|
||||
"provider": "dashscope",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"function_exact": "pmflow_ai_operating_mission"
|
||||
},
|
||||
{
|
||||
"name": "engine_low_semantic_context_exact",
|
||||
"enabled": true,
|
||||
"priority": 310,
|
||||
"tier": "low",
|
||||
"provider": "glm",
|
||||
"provider": "dashscope",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
|
|
@ -80,7 +97,7 @@
|
|||
"enabled": true,
|
||||
"priority": 300,
|
||||
"tier": "low",
|
||||
"provider": "glm",
|
||||
"provider": "dashscope",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
|
|
@ -150,7 +167,7 @@
|
|||
"enabled": true,
|
||||
"priority": 50,
|
||||
"tier": "low",
|
||||
"provider": "glm",
|
||||
"provider": "dashscope",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
# Database Configuration
|
||||
DATABASE_URL=postgresql+asyncpg://pmflow:pmflow_pass@db:5432/pmflow
|
||||
# 可选:启动方言守卫(postgresql / sqlite)。配置后若不一致将拒绝启动。
|
||||
PMFLOW_EXPECTED_DB_DIALECT=postgresql
|
||||
# For PostgreSQL with pgvector:
|
||||
# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5435/neural_sync
|
||||
# For local SQLite quick-start:
|
||||
# DATABASE_URL=sqlite+aiosqlite:///./neural_sync.db
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://:pmflow_redis_pass@redis:6379/0
|
||||
|
||||
# Knowledge Graph Incremental Scheduler
|
||||
KG_INCREMENTAL_INTERVAL_MINUTES=5
|
||||
KG_INCREMENTAL_DELTA_LIMIT=1000
|
||||
KG_INCREMENTAL_EVENT_BATCH=200
|
||||
KG_CODE_SCAN_INTERVAL_HOURS=6
|
||||
|
||||
# ========================================
|
||||
# LLM / OpenClaw Control Plane
|
||||
# ========================================
|
||||
# 单一控制面:PMFlow 仅调用 OpenClaw 网关(OpenAI-Compatible)
|
||||
LLM_PROVIDER=openai
|
||||
OPENAI_BASE_URL=http://openclaw:18789/v1
|
||||
OPENAI_API_KEY=<OPENCLAW_GATEWAY_TOKEN>
|
||||
|
||||
# OpenClaw 函数路由
|
||||
OPENCLAW_ROUTING_ENABLED=true
|
||||
OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json
|
||||
# OpenClaw agent 默认超时(秒):用于注入 chat/completions -> agents.defaults.timeoutSeconds
|
||||
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
||||
|
||||
# 关闭 PMFlow 内部 mixed routing,优先以 OpenClaw 规则为准
|
||||
LLM_MIXED_ROUTING_ENABLED=false
|
||||
LLM_ROUTING_DEFAULT_TIER=active
|
||||
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
|
||||
|
||||
# low/high/safety tier 默认模型(由网关解释)
|
||||
LLM_ROUTING_LOW_PROVIDER=openai
|
||||
LLM_ROUTING_LOW_MODEL=zai/glm-4-flash
|
||||
LLM_ROUTING_HIGH_PROVIDER=openai
|
||||
LLM_ROUTING_HIGH_MODEL=minimax/MiniMax-M2.5
|
||||
LLM_ROUTING_SAFETY_PROVIDER=openai
|
||||
LLM_ROUTING_SAFETY_MODEL=deepseek/deepseek-chat
|
||||
|
||||
# 应急开关:仅在网关不可用时允许直连 provider(默认关闭)
|
||||
LLM_DIRECT_EMERGENCY_ENABLED=false
|
||||
LLM_FALLBACK_PROVIDER=ollama
|
||||
|
||||
# Adaptive RAG 深入思考模式(前端开关=deep 时生效)
|
||||
RAG_DEEP_RETRIEVAL_STEPS=5
|
||||
RAG_DEEP_VECTOR_TOP_K=12
|
||||
RAG_DEEP_CONTEXT_BLOCKS=16
|
||||
RAG_DEEP_ANSWER_MAX_TOKENS=1400
|
||||
|
||||
# --- Emergency Direct Provider Keys (optional) ---
|
||||
GLM_API_KEY=
|
||||
GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
GLM_MODEL=glm-4-flash
|
||||
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
# --- Ollama 本地配置(应急本地兜底) ---
|
||||
OLLAMA_HOST=http://127.0.0.1:11434
|
||||
OLLAMA_CHAT_MODEL=llama3:8b
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
OLLAMA_MODEL=llama3:latest
|
||||
|
||||
# ========================================
|
||||
|
||||
# Multi-Model Voting Configuration (仅 Ollama 支持)
|
||||
# For high-impact signals (impact_score >= VOTING_THRESHOLD), multiple models will vote
|
||||
# Available models: llama3, llama3.2, qwen2.5, mistral, deepseek-coder, etc.
|
||||
VOTING_MODELS=llama3,qwen2.5,mistral
|
||||
VOTING_THRESHOLD=0.8
|
||||
|
||||
# Business Context for AI Strategic Analysis
|
||||
# Customize this to match your actual product/team
|
||||
# This context is used by the AI to evaluate external signals
|
||||
BUSINESS_CONTEXT=Pixelle-Video: An AI-powered automated short video generation platform. Core features: topic-to-video automation (script generation → AI images/videos → TTS → BGM → final video). Tech stack: Python, Streamlit, FastAPI, ComfyUI workflows. AI models: FLUX/Qwen/SDXL for images, WAN 2.1/2.2 for videos, Edge TTS/Index TTS for voice. Supports multiple pipelines: standard generation, custom assets, digital human, image-to-video, action transfer.
|
||||
|
||||
# Plane PM Tool Integration (optional, simulation mode if not set)
|
||||
PLANE_API_URL=https://api.plane.so/api/v1
|
||||
PLANE_API_KEY=
|
||||
PLANE_WORKSPACE_SLUG=
|
||||
PLANE_PROJECT_ID=
|
||||
|
||||
# Lark/Feishu Bot Integration (optional)
|
||||
LARK_APP_ID=
|
||||
LARK_APP_SECRET=
|
||||
LARK_VERIFICATION_TOKEN=
|
||||
# 飞书长连接重试策略(指数退避 1s -> 2s -> 4s,最多 3 次)
|
||||
FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS=1
|
||||
FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS=3
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"version": "v2",
|
||||
"updated_at": "2026-03-05T00:00:00Z",
|
||||
"source_of_truth": "openclaw",
|
||||
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
|
||||
"rules": [
|
||||
{
|
||||
"name": "engine_low_execute_glm_flash",
|
||||
"enabled": true,
|
||||
"priority": 300,
|
||||
"tier": "low",
|
||||
"provider": "openai",
|
||||
"model": "zai/glm-4-flash",
|
||||
"match_any": [
|
||||
"feishu_level_classification",
|
||||
"feishu_signal_extraction",
|
||||
"rag_intent_classification",
|
||||
"classify_intent",
|
||||
"feishu_humanized_reply",
|
||||
"extract_context_insights"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_high_reasoning_minimax",
|
||||
"enabled": true,
|
||||
"priority": 220,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "minimax/MiniMax-M2.5",
|
||||
"match_any": [
|
||||
"adaptive_rag_answer",
|
||||
"feishu_project_prefill",
|
||||
"feishu_task_orchestration",
|
||||
"generate_task_breakdown",
|
||||
"generate_charter_from_signal",
|
||||
"decompose_tasks_from_charter",
|
||||
"recommend_assignees_for_task",
|
||||
"evaluate_model_requirement",
|
||||
"calculate_alignment_score",
|
||||
"detect_cognitive_conflicts",
|
||||
"rag_db_project_semantic_retrieval",
|
||||
"workload_optimize_prompt",
|
||||
"analyze_cost_roi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_high_extended_reasoning",
|
||||
"enabled": true,
|
||||
"priority": 200,
|
||||
"tier": "high",
|
||||
"provider": "openai",
|
||||
"model": "minimax/MiniMax-M2.5",
|
||||
"match_any": [
|
||||
"analyze_food_calories",
|
||||
"feishu_new_project_brief",
|
||||
"feishu_project_signal_classification",
|
||||
"sstc_ai_breakdown",
|
||||
"sstc_charter_localize_zh",
|
||||
"sstc_charter_prefill",
|
||||
"skill_score_update",
|
||||
"test_engine_connection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "engine_safety_fallback",
|
||||
"enabled": true,
|
||||
"priority": 50,
|
||||
"tier": "safety",
|
||||
"provider": "openai",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"match_all": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
"""add ai project control tables
|
||||
|
||||
Revision ID: 20260421_add_ai_project_control_tables
|
||||
Revises: 20260415_add_semantic_facts
|
||||
Create Date: 2026-04-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260421_add_ai_project_control_tables"
|
||||
down_revision = "20260415_add_semantic_facts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _seed_model_catalog(bind) -> None:
|
||||
table = sa.table(
|
||||
"model_catalog_items",
|
||||
sa.column("tenant_id", sa.BigInteger()),
|
||||
sa.column("provider", sa.String()),
|
||||
sa.column("model_key", sa.String()),
|
||||
sa.column("display_name", sa.String()),
|
||||
sa.column("version", sa.String()),
|
||||
sa.column("modalities", _json_type(bind)),
|
||||
sa.column("capability_tags", _json_type(bind)),
|
||||
sa.column("strengths", _json_type(bind)),
|
||||
sa.column("limitations", _json_type(bind)),
|
||||
sa.column("compliance_status", sa.String()),
|
||||
sa.column("filing_status", sa.String()),
|
||||
sa.column("input_cost_per_mtok", sa.Numeric(12, 4)),
|
||||
sa.column("output_cost_per_mtok", sa.Numeric(12, 4)),
|
||||
sa.column("official_docs_url", sa.Text()),
|
||||
sa.column("is_active", sa.Boolean()),
|
||||
)
|
||||
op.bulk_insert(
|
||||
table,
|
||||
[
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "阿里云",
|
||||
"model_key": "qwen-plus",
|
||||
"display_name": "通义千问 Plus",
|
||||
"version": "latest",
|
||||
"modalities": ["text", "image"],
|
||||
"capability_tags": ["general", "rag", "reasoning", "agent", "code"],
|
||||
"strengths": ["中文通用能力强", "RAG 适配稳定", "工具调用成熟"],
|
||||
"limitations": ["不适合视频生成"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 0.8,
|
||||
"output_cost_per_mtok": 2.0,
|
||||
"official_docs_url": "https://help.aliyun.com/zh/model-studio/",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "火山引擎",
|
||||
"model_key": "doubao-pro",
|
||||
"display_name": "豆包 Pro",
|
||||
"version": "latest",
|
||||
"modalities": ["text", "image"],
|
||||
"capability_tags": ["general", "rag", "reasoning", "multimodal"],
|
||||
"strengths": ["长文本稳", "中文体验好", "成本适中"],
|
||||
"limitations": ["代码生成弱于专用模型"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 0.8,
|
||||
"output_cost_per_mtok": 2.0,
|
||||
"official_docs_url": "https://www.volcengine.com/product/doubao",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "百度智能云",
|
||||
"model_key": "ernie-4.0-turbo",
|
||||
"display_name": "文心一言 4.0 Turbo",
|
||||
"version": "latest",
|
||||
"modalities": ["text", "image"],
|
||||
"capability_tags": ["general", "rag", "reasoning", "multimodal"],
|
||||
"strengths": ["企业落地常见", "多模态均衡"],
|
||||
"limitations": ["综合成本偏高"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 1.2,
|
||||
"output_cost_per_mtok": 3.2,
|
||||
"official_docs_url": "https://cloud.baidu.com/product/wenxinworkshop",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "DeepSeek",
|
||||
"model_key": "deepseek-chat",
|
||||
"display_name": "DeepSeek Chat",
|
||||
"version": "latest",
|
||||
"modalities": ["text"],
|
||||
"capability_tags": ["general", "reasoning", "code", "agent"],
|
||||
"strengths": ["代码与推理性价比高", "适合技术团队"],
|
||||
"limitations": ["需要留意合规和交付边界"],
|
||||
"compliance_status": "review_required",
|
||||
"filing_status": "unknown",
|
||||
"input_cost_per_mtok": 0.27,
|
||||
"output_cost_per_mtok": 1.1,
|
||||
"official_docs_url": "https://platform.deepseek.com/",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"tenant_id": 1,
|
||||
"provider": "Moonshot AI",
|
||||
"model_key": "kimi-k2",
|
||||
"display_name": "Kimi K2",
|
||||
"version": "latest",
|
||||
"modalities": ["text"],
|
||||
"capability_tags": ["general", "long_context", "rag", "reasoning"],
|
||||
"strengths": ["长上下文强", "资料理解稳定"],
|
||||
"limitations": ["对多模态场景支持有限"],
|
||||
"compliance_status": "ready",
|
||||
"filing_status": "filed",
|
||||
"input_cost_per_mtok": 1.0,
|
||||
"output_cost_per_mtok": 3.0,
|
||||
"official_docs_url": "https://platform.moonshot.cn/",
|
||||
"is_active": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "model_catalog_items"):
|
||||
op.create_table(
|
||||
"model_catalog_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("provider", sa.String(length=120), nullable=False),
|
||||
sa.Column("model_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("version", sa.String(length=120), nullable=False),
|
||||
sa.Column("modalities", json_type, nullable=False),
|
||||
sa.Column("capability_tags", json_type, nullable=False),
|
||||
sa.Column("strengths", json_type, nullable=False),
|
||||
sa.Column("limitations", json_type, nullable=False),
|
||||
sa.Column("compliance_status", sa.String(length=50), nullable=False, server_default="review_required"),
|
||||
sa.Column("filing_status", sa.String(length=50), nullable=False, server_default="unknown"),
|
||||
sa.Column("input_cost_per_mtok", sa.DECIMAL(12, 4), nullable=True),
|
||||
sa.Column("output_cost_per_mtok", sa.DECIMAL(12, 4), nullable=True),
|
||||
sa.Column("official_docs_url", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"provider",
|
||||
"model_key",
|
||||
"version",
|
||||
name="uq_model_catalog_items_tenant_provider_key_version",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_model_catalog_items_tenant_id", "model_catalog_items", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_model_catalog_items_provider", "model_catalog_items", ["provider"], unique=False)
|
||||
op.create_index("ix_model_catalog_items_model_key", "model_catalog_items", ["model_key"], unique=False)
|
||||
op.create_index("ix_model_catalog_items_is_active", "model_catalog_items", ["is_active"], unique=False)
|
||||
_seed_model_catalog(bind)
|
||||
|
||||
table_specs = {
|
||||
"project_model_bindings": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("model_catalog_item_id", sa.BigInteger(), sa.ForeignKey("model_catalog_items.id"), nullable=False),
|
||||
sa.Column("selected_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("selection_reason", json_type, nullable=False),
|
||||
sa.Column("estimated_daily_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("estimated_monthly_cost_usd", sa.DECIMAL(12, 2), nullable=True),
|
||||
sa.Column("active_from", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("archived_at", sa.TIMESTAMP(), nullable=True),
|
||||
],
|
||||
"model_recommendation_snapshots": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("recommendation_scope", sa.String(length=32), nullable=False, server_default="charter"),
|
||||
sa.Column("snapshot_input", json_type, nullable=False),
|
||||
sa.Column("recommendations", json_type, nullable=False),
|
||||
sa.Column("top_model_catalog_item_id", sa.BigInteger(), sa.ForeignKey("model_catalog_items.id"), nullable=True),
|
||||
sa.Column("generated_by", sa.String(length=32), nullable=False, server_default="rule_engine"),
|
||||
],
|
||||
"model_usage_events": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("event_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("cost_usd", sa.DECIMAL(12, 4), nullable=False, server_default="0"),
|
||||
sa.Column("event_payload", json_type, nullable=False),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "event_key", name="uq_model_usage_events_tenant_event_key"),
|
||||
],
|
||||
"model_eval_runs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("success_criteria", json_type, nullable=False),
|
||||
sa.Column("test_cases", json_type, nullable=False),
|
||||
sa.Column("summary", json_type, nullable=False),
|
||||
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
|
||||
],
|
||||
"prompt_packages": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("package_kind", sa.String(length=32), nullable=False, server_default="development"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("package_text", sa.Text(), nullable=False),
|
||||
sa.Column("package_payload", json_type, nullable=False),
|
||||
sa.Column("generated_from", json_type, nullable=False),
|
||||
],
|
||||
"dev_workspace_bindings": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("workspace_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("repo_root", sa.Text(), nullable=True),
|
||||
sa.Column("editor_type", sa.String(length=32), nullable=False, server_default="vscode"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("last_seen_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "workspace_key", name="uq_dev_workspace_bindings_tenant_workspace_key"),
|
||||
],
|
||||
"repo_bindings": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("provider", sa.String(length=32), nullable=False, server_default="github"),
|
||||
sa.Column("external_repo_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("owner", sa.String(length=255), nullable=False),
|
||||
sa.Column("repo_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("default_branch", sa.String(length=255), nullable=False, server_default="main"),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("webhook_secret_hint", sa.String(length=255), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "provider", "owner", "repo_name", name="uq_repo_bindings_tenant_provider_repo"),
|
||||
],
|
||||
"task_code_links": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=False),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=False),
|
||||
sa.Column("branch_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("pr_number", sa.Integer(), nullable=True),
|
||||
sa.Column("last_commit_sha", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="linked"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"repo_binding_id",
|
||||
"branch_name",
|
||||
name="uq_task_code_links_tenant_task_repo_branch",
|
||||
),
|
||||
],
|
||||
"progress_events": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), sa.ForeignKey("tasks.id"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=True),
|
||||
sa.Column("workspace_binding_id", sa.BigInteger(), sa.ForeignKey("dev_workspace_bindings.id"), nullable=True),
|
||||
sa.Column("event_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("derived_task_status", sa.String(length=32), nullable=True),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("payload", json_type, nullable=False),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
|
||||
],
|
||||
"dev_sessions": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("workspace_binding_id", sa.BigInteger(), sa.ForeignKey("dev_workspace_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("heartbeat_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("ended_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("session_payload", json_type, nullable=False),
|
||||
],
|
||||
"release_validation_runs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("release_node_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
|
||||
sa.Column("key_metrics", json_type, nullable=False),
|
||||
sa.Column("risk_items", json_type, nullable=False),
|
||||
sa.Column("rollback_plan", json_type, nullable=False),
|
||||
sa.Column("validation_summary", json_type, nullable=False),
|
||||
],
|
||||
"feedback_ingests": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("ingested_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("title", sa.String(length=255), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("metrics", json_type, nullable=False),
|
||||
sa.Column("samples", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
],
|
||||
"iteration_recommendations": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("feedback_ingest_id", sa.BigInteger(), sa.ForeignKey("feedback_ingests.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
|
||||
sa.Column("priority", sa.String(length=32), nullable=False, server_default="medium"),
|
||||
sa.Column("recommendation_summary", json_type, nullable=False),
|
||||
sa.Column("proposed_charter_patch", json_type, nullable=False),
|
||||
sa.Column("proposed_tasks", json_type, nullable=False),
|
||||
],
|
||||
"revenue_events": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="manual"),
|
||||
sa.Column("currency", sa.String(length=16), nullable=False, server_default="USD"),
|
||||
sa.Column("amount_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("external_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("event_payload", json_type, nullable=False),
|
||||
sa.Column("occurred_at", sa.TIMESTAMP(), nullable=True),
|
||||
],
|
||||
"roi_snapshots": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("range_key", sa.String(length=32), nullable=False, server_default="monthly"),
|
||||
sa.Column("window_start", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("window_end", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("total_revenue_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("total_cost_usd", sa.DECIMAL(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("roi_ratio", sa.DECIMAL(12, 4), nullable=True),
|
||||
sa.Column("snapshot_payload", json_type, nullable=False),
|
||||
],
|
||||
"compliance_check_runs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("charter_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("release_node_id", sa.BigInteger(), sa.ForeignKey("truth_nodes.id"), nullable=True),
|
||||
sa.Column("project_model_binding_id", sa.BigInteger(), sa.ForeignKey("project_model_bindings.id"), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("score", sa.DECIMAL(5, 2), nullable=True),
|
||||
sa.Column("checklist", json_type, nullable=False),
|
||||
sa.Column("issues", json_type, nullable=False),
|
||||
sa.Column("summary", json_type, nullable=False),
|
||||
],
|
||||
"evidence_packs": [
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("compliance_check_run_id", sa.BigInteger(), sa.ForeignKey("compliance_check_runs.id"), nullable=True),
|
||||
sa.Column("package_kind", sa.String(length=32), nullable=False, server_default="compliance_release"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
|
||||
sa.Column("content", json_type, nullable=False),
|
||||
sa.Column("export_payload", json_type, nullable=False),
|
||||
],
|
||||
}
|
||||
|
||||
for table_name, columns in table_specs.items():
|
||||
if _table_exists(bind, table_name):
|
||||
continue
|
||||
op.create_table(table_name, *columns)
|
||||
op.create_index(f"ix_{table_name}_tenant_id", table_name, ["tenant_id"], unique=False)
|
||||
|
||||
extra_indexes = {
|
||||
"project_model_bindings": ["project_id", "charter_id", "model_catalog_item_id", "status"],
|
||||
"model_recommendation_snapshots": ["project_id", "charter_id"],
|
||||
"model_usage_events": ["project_id", "project_model_binding_id", "source_type", "occurred_at"],
|
||||
"model_eval_runs": ["project_id", "charter_id", "project_model_binding_id", "status"],
|
||||
"prompt_packages": ["project_id", "charter_id", "task_id", "project_model_binding_id", "status"],
|
||||
"dev_workspace_bindings": ["project_id", "user_id", "status"],
|
||||
"repo_bindings": ["project_id", "provider", "status"],
|
||||
"task_code_links": ["task_id", "repo_binding_id", "pr_number", "status"],
|
||||
"progress_events": ["task_id", "project_id", "user_id", "repo_binding_id", "workspace_binding_id", "event_type", "source", "occurred_at"],
|
||||
"dev_sessions": ["project_id", "user_id", "workspace_binding_id", "status"],
|
||||
"release_validation_runs": ["project_id", "charter_id", "release_node_id", "project_model_binding_id", "status"],
|
||||
"feedback_ingests": ["project_id", "charter_id", "ingested_by_user_id", "source_type", "status"],
|
||||
"iteration_recommendations": ["project_id", "charter_id", "feedback_ingest_id", "status", "priority"],
|
||||
"revenue_events": ["project_id", "source_type", "external_ref", "occurred_at"],
|
||||
"roi_snapshots": ["project_id", "range_key"],
|
||||
"compliance_check_runs": ["project_id", "charter_id", "release_node_id", "project_model_binding_id", "status"],
|
||||
"evidence_packs": ["project_id", "compliance_check_run_id", "package_kind", "status"],
|
||||
}
|
||||
for table_name, columns in extra_indexes.items():
|
||||
for column_name in columns:
|
||||
op.create_index(f"ix_{table_name}_{column_name}", table_name, [column_name], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
tables = [
|
||||
"evidence_packs",
|
||||
"compliance_check_runs",
|
||||
"roi_snapshots",
|
||||
"revenue_events",
|
||||
"iteration_recommendations",
|
||||
"feedback_ingests",
|
||||
"release_validation_runs",
|
||||
"dev_sessions",
|
||||
"progress_events",
|
||||
"task_code_links",
|
||||
"repo_bindings",
|
||||
"dev_workspace_bindings",
|
||||
"prompt_packages",
|
||||
"model_eval_runs",
|
||||
"model_usage_events",
|
||||
"model_recommendation_snapshots",
|
||||
"project_model_bindings",
|
||||
"model_catalog_items",
|
||||
]
|
||||
for table_name in tables:
|
||||
if not _table_exists(bind, table_name):
|
||||
continue
|
||||
for index in sa.inspect(bind).get_indexes(table_name):
|
||||
op.drop_index(index["name"], table_name=table_name)
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
"""finalize v1 productization
|
||||
|
||||
Revision ID: 20260421_finalize_v1_productization
|
||||
Revises: 20260421_add_ai_project_control_tables
|
||||
Create Date: 2026-04-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260421_finalize_v1_productization"
|
||||
down_revision = "20260421_add_ai_project_control_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(column.get("name") == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if _table_exists(bind, "users") and _column_exists(bind, "users", "role"):
|
||||
op.execute("UPDATE users SET role = 'OWNER' WHERE UPPER(role) = 'BOSS'")
|
||||
op.execute("UPDATE users SET role = 'REVIEWER' WHERE UPPER(role) = 'ADMIN'")
|
||||
op.execute("UPDATE users SET role = 'BUILDER' WHERE UPPER(role) IN ('AI', 'FE', 'BE')")
|
||||
|
||||
if _table_exists(bind, "revenue_events") and not _column_exists(bind, "revenue_events", "category"):
|
||||
op.add_column(
|
||||
"revenue_events",
|
||||
sa.Column("category", sa.String(length=32), nullable=False, server_default="other"),
|
||||
)
|
||||
op.create_index("ix_revenue_events_category", "revenue_events", ["category"], unique=False)
|
||||
|
||||
if _table_exists(bind, "repo_bindings") and not _column_exists(bind, "repo_bindings", "installation_account_login"):
|
||||
op.add_column(
|
||||
"repo_bindings",
|
||||
sa.Column("installation_account_login", sa.String(length=255), nullable=True),
|
||||
)
|
||||
|
||||
if _table_exists(bind, "progress_events") and not _column_exists(bind, "progress_events", "delivery_id"):
|
||||
op.add_column("progress_events", sa.Column("delivery_id", sa.String(length=255), nullable=True))
|
||||
op.create_index("ix_progress_events_delivery_id", "progress_events", ["delivery_id"], unique=False)
|
||||
if _table_exists(bind, "progress_events") and not _column_exists(bind, "progress_events", "event_source_id"):
|
||||
op.add_column("progress_events", sa.Column("event_source_id", sa.String(length=255), nullable=True))
|
||||
op.create_index("ix_progress_events_event_source_id", "progress_events", ["event_source_id"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "github_app_installations"):
|
||||
op.create_table(
|
||||
"github_app_installations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("account_login", sa.String(length=255), nullable=False),
|
||||
sa.Column("account_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("app_slug", sa.String(length=255), nullable=True),
|
||||
sa.Column("target_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("permissions", json_type, nullable=False),
|
||||
sa.Column("events", json_type, nullable=False),
|
||||
sa.Column("metadata", json_type, nullable=False),
|
||||
sa.Column("last_synced_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "installation_id", name="uq_github_app_installations_tenant_installation"),
|
||||
)
|
||||
op.create_index("ix_github_app_installations_tenant_id", "github_app_installations", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_github_app_installations_installation_id", "github_app_installations", ["installation_id"], unique=False)
|
||||
op.create_index("ix_github_app_installations_account_login", "github_app_installations", ["account_login"], unique=False)
|
||||
op.create_index("ix_github_app_installations_status", "github_app_installations", ["status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "github_app_repositories"):
|
||||
op.create_table(
|
||||
"github_app_repositories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("external_repo_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("owner", sa.String(length=255), nullable=False),
|
||||
sa.Column("repo_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("full_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("default_branch", sa.String(length=255), nullable=True),
|
||||
sa.Column("is_private", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("permissions", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("last_synced_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"installation_id",
|
||||
"external_repo_id",
|
||||
name="uq_github_app_repositories_repo",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_github_app_repositories_tenant_id", "github_app_repositories", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_installation_id", "github_app_repositories", ["installation_id"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_external_repo_id", "github_app_repositories", ["external_repo_id"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_owner", "github_app_repositories", ["owner"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_repo_name", "github_app_repositories", ["repo_name"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_full_name", "github_app_repositories", ["full_name"], unique=False)
|
||||
op.create_index("ix_github_app_repositories_status", "github_app_repositories", ["status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "github_webhook_deliveries"):
|
||||
op.create_table(
|
||||
"github_webhook_deliveries",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("delivery_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), sa.ForeignKey("repo_bindings.id"), nullable=True),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("installation_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="received"),
|
||||
sa.Column("failure_reason", sa.Text(), nullable=True),
|
||||
sa.Column("payload", json_type, nullable=False),
|
||||
sa.Column("received_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "delivery_id", name="uq_github_webhook_deliveries_tenant_delivery"),
|
||||
)
|
||||
op.create_index("ix_github_webhook_deliveries_tenant_id", "github_webhook_deliveries", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_delivery_id", "github_webhook_deliveries", ["delivery_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_event_type", "github_webhook_deliveries", ["event_type"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_repo_binding_id", "github_webhook_deliveries", ["repo_binding_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_project_id", "github_webhook_deliveries", ["project_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_installation_id", "github_webhook_deliveries", ["installation_id"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_status", "github_webhook_deliveries", ["status"], unique=False)
|
||||
op.create_index("ix_github_webhook_deliveries_received_at", "github_webhook_deliveries", ["received_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "github_webhook_deliveries"):
|
||||
op.drop_table("github_webhook_deliveries")
|
||||
if _table_exists(bind, "github_app_repositories"):
|
||||
op.drop_table("github_app_repositories")
|
||||
if _table_exists(bind, "github_app_installations"):
|
||||
op.drop_table("github_app_installations")
|
||||
|
||||
if _table_exists(bind, "progress_events") and _column_exists(bind, "progress_events", "event_source_id"):
|
||||
op.drop_index("ix_progress_events_event_source_id", table_name="progress_events")
|
||||
op.drop_column("progress_events", "event_source_id")
|
||||
if _table_exists(bind, "progress_events") and _column_exists(bind, "progress_events", "delivery_id"):
|
||||
op.drop_index("ix_progress_events_delivery_id", table_name="progress_events")
|
||||
op.drop_column("progress_events", "delivery_id")
|
||||
|
||||
if _table_exists(bind, "repo_bindings") and _column_exists(bind, "repo_bindings", "installation_account_login"):
|
||||
op.drop_column("repo_bindings", "installation_account_login")
|
||||
|
||||
if _table_exists(bind, "revenue_events") and _column_exists(bind, "revenue_events", "category"):
|
||||
op.drop_index("ix_revenue_events_category", table_name="revenue_events")
|
||||
op.drop_column("revenue_events", "category")
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"""add pmflow launch tables
|
||||
|
||||
Revision ID: 20260425_add_pmflow_launch_tables
|
||||
Revises: 20260421_finalize_v1_productization
|
||||
Create Date: 2026-04-25
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260425_add_pmflow_launch_tables"
|
||||
down_revision = "20260421_finalize_v1_productization"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "pmflow_launch_drafts"):
|
||||
op.create_table(
|
||||
"pmflow_launch_drafts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("draft_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("input_text", sa.Text(), nullable=False),
|
||||
sa.Column("input_type", sa.String(length=32), nullable=False, server_default="text"),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="web"),
|
||||
sa.Column("attachments", json_type, nullable=False),
|
||||
sa.Column("draft_payload", json_type, nullable=False),
|
||||
sa.Column("edited_payload", json_type, nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="draft"),
|
||||
sa.Column("confidence", sa.Float(), nullable=True),
|
||||
sa.Column("warnings", json_type, nullable=False),
|
||||
sa.Column("confirmed_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("confirmed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("confirm_request_hash", sa.String(length=128), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "draft_id", name="uq_pmflow_launch_drafts_tenant_draft_id"),
|
||||
)
|
||||
op.create_index("ix_pmflow_launch_drafts_tenant_id", "pmflow_launch_drafts", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_drafts_user_id", "pmflow_launch_drafts", ["user_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_drafts_draft_id", "pmflow_launch_drafts", ["draft_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_drafts_status", "pmflow_launch_drafts", ["status"], unique=False)
|
||||
op.create_index(
|
||||
"ix_pmflow_launch_drafts_confirmed_project_id",
|
||||
"pmflow_launch_drafts",
|
||||
["confirmed_project_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index("ix_pmflow_launch_drafts_expires_at", "pmflow_launch_drafts", ["expires_at"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_threads"):
|
||||
op.create_table(
|
||||
"pmflow_threads",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("launch_draft_id", sa.BigInteger(), sa.ForeignKey("pmflow_launch_drafts.id"), nullable=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("objective", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="project_launch"),
|
||||
sa.UniqueConstraint("tenant_id", "project_id", "source", name="uq_pmflow_threads_tenant_project_source"),
|
||||
)
|
||||
op.create_index("ix_pmflow_threads_tenant_id", "pmflow_threads", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_project_id", "pmflow_threads", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_launch_draft_id", "pmflow_threads", ["launch_draft_id"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_status", "pmflow_threads", ["status"], unique=False)
|
||||
op.create_index("ix_pmflow_threads_source", "pmflow_threads", ["source"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_thread_messages"):
|
||||
op.create_table(
|
||||
"pmflow_thread_messages",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("launch_draft_id", sa.BigInteger(), sa.ForeignKey("pmflow_launch_drafts.id"), nullable=True),
|
||||
sa.Column("role", sa.String(length=32), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="project_launch"),
|
||||
sa.Column("attachments", json_type, nullable=False),
|
||||
sa.Column("message_metadata", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="sent"),
|
||||
)
|
||||
op.create_index("ix_pmflow_thread_messages_tenant_id", "pmflow_thread_messages", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_thread_messages_thread_id", "pmflow_thread_messages", ["thread_id"], unique=False)
|
||||
op.create_index("ix_pmflow_thread_messages_project_id", "pmflow_thread_messages", ["project_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_pmflow_thread_messages_launch_draft_id",
|
||||
"pmflow_thread_messages",
|
||||
["launch_draft_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index("ix_pmflow_thread_messages_status", "pmflow_thread_messages", ["status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
for index_name, table_name in (
|
||||
("ix_pmflow_thread_messages_status", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_launch_draft_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_project_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_thread_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_thread_messages_tenant_id", "pmflow_thread_messages"),
|
||||
("ix_pmflow_threads_source", "pmflow_threads"),
|
||||
("ix_pmflow_threads_status", "pmflow_threads"),
|
||||
("ix_pmflow_threads_launch_draft_id", "pmflow_threads"),
|
||||
("ix_pmflow_threads_project_id", "pmflow_threads"),
|
||||
("ix_pmflow_threads_tenant_id", "pmflow_threads"),
|
||||
("ix_pmflow_launch_drafts_expires_at", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_confirmed_project_id", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_status", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_draft_id", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_user_id", "pmflow_launch_drafts"),
|
||||
("ix_pmflow_launch_drafts_tenant_id", "pmflow_launch_drafts"),
|
||||
):
|
||||
if _table_exists(bind, table_name):
|
||||
try:
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
except Exception:
|
||||
pass
|
||||
for table_name in ("pmflow_thread_messages", "pmflow_threads", "pmflow_launch_drafts"):
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
"""add pmflow change review tables
|
||||
|
||||
Revision ID: 20260426_add_pmflow_change_review_tables
|
||||
Revises: 20260425_add_pmflow_launch_tables
|
||||
Create Date: 2026-04-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260426_add_pmflow_change_review_tables"
|
||||
down_revision = "20260425_add_pmflow_launch_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "pmflow_change_sets"):
|
||||
op.create_table(
|
||||
"pmflow_change_sets",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=False),
|
||||
sa.Column("source_message_id", sa.BigInteger(), sa.ForeignKey("pmflow_thread_messages.id"), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("headline", sa.String(length=255), nullable=False),
|
||||
sa.Column("extraction_scope", sa.String(length=64), nullable=False, server_default="latest_message"),
|
||||
sa.Column("routing_meta", json_type, nullable=False),
|
||||
sa.Column("llm_call_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("committed_at", sa.TIMESTAMP(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_pmflow_change_sets_tenant_id", "pmflow_change_sets", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_project_id", "pmflow_change_sets", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_thread_id", "pmflow_change_sets", ["thread_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_source_message_id", "pmflow_change_sets", ["source_message_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_status", "pmflow_change_sets", ["status"], unique=False)
|
||||
op.create_index("ix_pmflow_change_sets_created_by", "pmflow_change_sets", ["created_by"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_change_items"):
|
||||
op.create_table(
|
||||
"pmflow_change_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("group", sa.String(length=32), nullable=False),
|
||||
sa.Column("object_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("object_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("diff", sa.Text(), nullable=False),
|
||||
sa.Column("payload", json_type, nullable=False),
|
||||
sa.Column("edited_payload", json_type, nullable=True),
|
||||
sa.Column("confidence", sa.Float(), nullable=True),
|
||||
sa.Column("risk_level", sa.String(length=16), nullable=False, server_default="medium"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending_review"),
|
||||
sa.Column("reviewed_at", sa.TIMESTAMP(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_pmflow_change_items_tenant_id", "pmflow_change_items", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_items_change_set_id", "pmflow_change_items", ["change_set_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_items_project_id", "pmflow_change_items", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_change_items_status", "pmflow_change_items", ["status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_approvals"):
|
||||
op.create_table(
|
||||
"pmflow_approvals",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
|
||||
sa.Column("change_item_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_items.id"), nullable=False),
|
||||
sa.Column("actor_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("action", sa.String(length=32), nullable=False),
|
||||
sa.Column("decision", sa.String(length=32), nullable=False),
|
||||
sa.Column("before_status", sa.String(length=32), nullable=False),
|
||||
sa.Column("after_status", sa.String(length=32), nullable=False),
|
||||
sa.Column("before_payload", json_type, nullable=False),
|
||||
sa.Column("after_payload", json_type, nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_pmflow_approvals_tenant_id", "pmflow_approvals", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_project_id", "pmflow_approvals", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_change_set_id", "pmflow_approvals", ["change_set_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_change_item_id", "pmflow_approvals", ["change_item_id"], unique=False)
|
||||
op.create_index("ix_pmflow_approvals_actor_user_id", "pmflow_approvals", ["actor_user_id"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "pmflow_evidence_links"):
|
||||
op.create_table(
|
||||
"pmflow_evidence_links",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=False),
|
||||
sa.Column("change_item_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_items.id"), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("excerpt", sa.Text(), nullable=False),
|
||||
sa.Column("display_label", sa.String(length=255), nullable=False),
|
||||
sa.Column("evidence_metadata", json_type, nullable=False),
|
||||
)
|
||||
op.create_index("ix_pmflow_evidence_links_tenant_id", "pmflow_evidence_links", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_project_id", "pmflow_evidence_links", ["project_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_change_set_id", "pmflow_evidence_links", ["change_set_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_change_item_id", "pmflow_evidence_links", ["change_item_id"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_source_type", "pmflow_evidence_links", ["source_type"], unique=False)
|
||||
op.create_index("ix_pmflow_evidence_links_source_id", "pmflow_evidence_links", ["source_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
for index_name, table_name in (
|
||||
("ix_pmflow_evidence_links_source_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_source_type", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_change_item_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_change_set_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_project_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_evidence_links_tenant_id", "pmflow_evidence_links"),
|
||||
("ix_pmflow_approvals_actor_user_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_change_item_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_change_set_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_project_id", "pmflow_approvals"),
|
||||
("ix_pmflow_approvals_tenant_id", "pmflow_approvals"),
|
||||
("ix_pmflow_change_items_status", "pmflow_change_items"),
|
||||
("ix_pmflow_change_items_project_id", "pmflow_change_items"),
|
||||
("ix_pmflow_change_items_change_set_id", "pmflow_change_items"),
|
||||
("ix_pmflow_change_items_tenant_id", "pmflow_change_items"),
|
||||
("ix_pmflow_change_sets_created_by", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_status", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_source_message_id", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_thread_id", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_project_id", "pmflow_change_sets"),
|
||||
("ix_pmflow_change_sets_tenant_id", "pmflow_change_sets"),
|
||||
):
|
||||
if _table_exists(bind, table_name):
|
||||
try:
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
except Exception:
|
||||
pass
|
||||
for table_name in ("pmflow_evidence_links", "pmflow_approvals", "pmflow_change_items", "pmflow_change_sets"):
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"""add human capacity overrides
|
||||
|
||||
Revision ID: 20260427_add_human_capacity_overrides
|
||||
Revises: 20260426_add_pmflow_change_review_tables
|
||||
Create Date: 2026-04-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260427_add_human_capacity_overrides"
|
||||
down_revision = "20260426_add_pmflow_change_review_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if not _table_exists(bind, "human_capacity_overrides"):
|
||||
op.create_table(
|
||||
"human_capacity_overrides",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("date", sa.Date(), nullable=False),
|
||||
sa.Column("override_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("added_minutes", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("overtime_type", sa.String(length=32), nullable=True),
|
||||
sa.Column("max_planned_utilization", sa.Float(), nullable=True),
|
||||
sa.Column("work_type_bias", json_type, nullable=False),
|
||||
sa.Column("high_risk_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
)
|
||||
op.create_index("ix_human_capacity_overrides_tenant_id", "human_capacity_overrides", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_human_capacity_overrides_project_date", "human_capacity_overrides", ["project_id", "date"], unique=False)
|
||||
op.create_index("ix_human_capacity_overrides_user_date", "human_capacity_overrides", ["user_id", "date"], unique=False)
|
||||
op.create_index("ix_human_capacity_overrides_created_by", "human_capacity_overrides", ["created_by"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "human_capacity_overrides"):
|
||||
op.drop_index("ix_human_capacity_overrides_created_by", table_name="human_capacity_overrides")
|
||||
op.drop_index("ix_human_capacity_overrides_user_date", table_name="human_capacity_overrides")
|
||||
op.drop_index("ix_human_capacity_overrides_project_date", table_name="human_capacity_overrides")
|
||||
op.drop_index("ix_human_capacity_overrides_tenant_id", table_name="human_capacity_overrides")
|
||||
op.drop_table("human_capacity_overrides")
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
"""add phone auth identity
|
||||
|
||||
Revision ID: 20260428_add_phone_auth_identity
|
||||
Revises: 20260428_repair_admin_yangyu_workspace_state
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260428_add_phone_auth_identity"
|
||||
down_revision = "20260428_repair_admin_yangyu_workspace_state"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if _table_exists(bind, "users"):
|
||||
for column in (
|
||||
sa.Column("phone_country_code", sa.String(length=8), nullable=True),
|
||||
sa.Column("phone_number", sa.String(length=32), nullable=True),
|
||||
sa.Column("phone_e164", sa.String(length=32), nullable=True),
|
||||
sa.Column("phone_verified_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("last_login_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("login_method", sa.String(length=32), nullable=True),
|
||||
):
|
||||
if not _column_exists(bind, "users", column.name):
|
||||
op.add_column("users", column)
|
||||
if not _index_exists(bind, "users", "ix_users_phone_e164"):
|
||||
op.create_index("ix_users_phone_e164", "users", ["phone_e164"], unique=True)
|
||||
|
||||
if _table_exists(bind, "tenant_invitations"):
|
||||
for column in (
|
||||
sa.Column("phone_country_code", sa.String(length=8), nullable=True),
|
||||
sa.Column("phone_number", sa.String(length=32), nullable=True),
|
||||
sa.Column("phone_e164", sa.String(length=32), nullable=True),
|
||||
):
|
||||
if not _column_exists(bind, "tenant_invitations", column.name):
|
||||
op.add_column("tenant_invitations", column)
|
||||
if not _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_phone_e164"):
|
||||
op.create_index("ix_tenant_invitations_phone_e164", "tenant_invitations", ["phone_e164"], unique=False)
|
||||
if bind.dialect.name == "postgresql" and not _index_exists(
|
||||
bind,
|
||||
"tenant_invitations",
|
||||
"uq_tenant_invitations_pending_phone",
|
||||
):
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX uq_tenant_invitations_pending_phone
|
||||
ON tenant_invitations (tenant_id, phone_e164)
|
||||
WHERE status = 'pending' AND phone_e164 IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
if not _table_exists(bind, "phone_verification_codes"):
|
||||
op.create_table(
|
||||
"phone_verification_codes",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("phone_e164", sa.String(length=32), nullable=False, index=True),
|
||||
sa.Column("purpose", sa.String(length=32), nullable=False, index=True),
|
||||
sa.Column("code_hash", sa.String(length=128), nullable=False),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("consumed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("request_ip", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_phone_verification_codes_phone_purpose_created",
|
||||
"phone_verification_codes",
|
||||
["phone_e164", "purpose", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "phone_verification_codes"):
|
||||
if _index_exists(bind, "phone_verification_codes", "ix_phone_verification_codes_phone_purpose_created"):
|
||||
op.drop_index("ix_phone_verification_codes_phone_purpose_created", table_name="phone_verification_codes")
|
||||
op.drop_table("phone_verification_codes")
|
||||
if _table_exists(bind, "tenant_invitations"):
|
||||
if bind.dialect.name == "postgresql" and _index_exists(bind, "tenant_invitations", "uq_tenant_invitations_pending_phone"):
|
||||
op.drop_index("uq_tenant_invitations_pending_phone", table_name="tenant_invitations")
|
||||
if _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_phone_e164"):
|
||||
op.drop_index("ix_tenant_invitations_phone_e164", table_name="tenant_invitations")
|
||||
for column_name in ("phone_e164", "phone_number", "phone_country_code"):
|
||||
if _column_exists(bind, "tenant_invitations", column_name):
|
||||
op.drop_column("tenant_invitations", column_name)
|
||||
if _table_exists(bind, "users"):
|
||||
if _index_exists(bind, "users", "ix_users_phone_e164"):
|
||||
op.drop_index("ix_users_phone_e164", table_name="users")
|
||||
for column_name in (
|
||||
"login_method",
|
||||
"last_login_at",
|
||||
"phone_verified_at",
|
||||
"phone_e164",
|
||||
"phone_number",
|
||||
"phone_country_code",
|
||||
):
|
||||
if _column_exists(bind, "users", column_name):
|
||||
op.drop_column("users", column_name)
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"""add platform memberships
|
||||
|
||||
Revision ID: 20260428_add_platform_memberships
|
||||
Revises: 20260428_add_phone_auth_identity
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260428_add_platform_memberships"
|
||||
down_revision = "20260428_add_phone_auth_identity"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "platform_memberships"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"platform_memberships",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("role", sa.String(length=50), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.UniqueConstraint("user_id", "role", name="uq_platform_memberships_user_role"),
|
||||
)
|
||||
op.create_index("ix_platform_memberships_user_id", "platform_memberships", ["user_id"], unique=False)
|
||||
op.create_index("ix_platform_memberships_status", "platform_memberships", ["status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "platform_memberships"):
|
||||
return
|
||||
if _index_exists(bind, "platform_memberships", "ix_platform_memberships_status"):
|
||||
op.drop_index("ix_platform_memberships_status", table_name="platform_memberships")
|
||||
if _index_exists(bind, "platform_memberships", "ix_platform_memberships_user_id"):
|
||||
op.drop_index("ix_platform_memberships_user_id", table_name="platform_memberships")
|
||||
op.drop_table("platform_memberships")
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"""add saved idea enrichment fields
|
||||
|
||||
Revision ID: 20260428_add_saved_idea_enrichment
|
||||
Revises: 20260428_add_saved_ideas
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260428_add_saved_idea_enrichment"
|
||||
down_revision = "20260428_add_saved_ideas"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "saved_ideas"):
|
||||
return
|
||||
if not _column_exists(bind, "saved_ideas", "enrichment_status"):
|
||||
op.add_column(
|
||||
"saved_ideas",
|
||||
sa.Column("enrichment_status", sa.String(length=32), nullable=False, server_default="baseline"),
|
||||
)
|
||||
if not _column_exists(bind, "saved_ideas", "enrichment_payload"):
|
||||
op.add_column("saved_ideas", sa.Column("enrichment_payload", _json_type(bind), nullable=True))
|
||||
if not _column_exists(bind, "saved_ideas", "enrichment_error"):
|
||||
op.add_column("saved_ideas", sa.Column("enrichment_error", sa.Text(), nullable=True))
|
||||
if not _column_exists(bind, "saved_ideas", "enriched_at"):
|
||||
op.add_column("saved_ideas", sa.Column("enriched_at", sa.TIMESTAMP(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "saved_ideas"):
|
||||
return
|
||||
for column_name in ("enriched_at", "enrichment_error", "enrichment_payload", "enrichment_status"):
|
||||
if _column_exists(bind, "saved_ideas", column_name):
|
||||
op.drop_column("saved_ideas", column_name)
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"""add saved ideas
|
||||
|
||||
Revision ID: 20260428_add_saved_ideas
|
||||
Revises: 20260428_add_platform_memberships
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260428_add_saved_ideas"
|
||||
down_revision = "20260428_add_platform_memberships"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "saved_ideas"):
|
||||
return
|
||||
|
||||
json_type = _json_type(bind)
|
||||
op.create_table(
|
||||
"saved_ideas",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("idea_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("raw_text", sa.Text(), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("source_type", sa.String(length=32), nullable=False, server_default="project_launch"),
|
||||
sa.Column("source_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("input_mode", sa.String(length=32), nullable=False, server_default="idea"),
|
||||
sa.Column("attachments", json_type, nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="saved"),
|
||||
sa.Column("ai_summary", sa.Text(), nullable=True),
|
||||
sa.Column("missing_questions", json_type, nullable=False),
|
||||
sa.Column("readiness_score", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("launch_draft_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("promoted_project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=True),
|
||||
sa.Column("promoted_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("archived_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "idea_id", name="uq_saved_ideas_tenant_idea_id"),
|
||||
)
|
||||
op.create_index("ix_saved_ideas_tenant_id", "saved_ideas", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_saved_ideas_user_id", "saved_ideas", ["user_id"], unique=False)
|
||||
op.create_index("ix_saved_ideas_idea_id", "saved_ideas", ["idea_id"], unique=False)
|
||||
op.create_index("ix_saved_ideas_source_type", "saved_ideas", ["source_type"], unique=False)
|
||||
op.create_index("ix_saved_ideas_status", "saved_ideas", ["status"], unique=False)
|
||||
op.create_index("ix_saved_ideas_launch_draft_id", "saved_ideas", ["launch_draft_id"], unique=False)
|
||||
op.create_index("ix_saved_ideas_promoted_project_id", "saved_ideas", ["promoted_project_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "saved_ideas"):
|
||||
return
|
||||
for index_name in (
|
||||
"ix_saved_ideas_promoted_project_id",
|
||||
"ix_saved_ideas_launch_draft_id",
|
||||
"ix_saved_ideas_status",
|
||||
"ix_saved_ideas_source_type",
|
||||
"ix_saved_ideas_idea_id",
|
||||
"ix_saved_ideas_user_id",
|
||||
"ix_saved_ideas_tenant_id",
|
||||
):
|
||||
op.drop_index(index_name, table_name="saved_ideas")
|
||||
op.drop_table("saved_ideas")
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
"""add workspace memberships
|
||||
|
||||
Revision ID: 20260428_add_workspace_memberships
|
||||
Revises: 20260427_add_human_capacity_overrides
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260428_add_workspace_memberships"
|
||||
down_revision = "20260427_add_human_capacity_overrides"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
json_type = _json_type(bind)
|
||||
|
||||
if _table_exists(bind, "tenants"):
|
||||
if not _column_exists(bind, "tenants", "workspace_type"):
|
||||
op.add_column("tenants", sa.Column("workspace_type", sa.String(length=32), nullable=False, server_default="team"))
|
||||
if not _column_exists(bind, "tenants", "slug"):
|
||||
op.add_column("tenants", sa.Column("slug", sa.String(length=120), nullable=True))
|
||||
if not _column_exists(bind, "tenants", "owner_user_id"):
|
||||
op.add_column("tenants", sa.Column("owner_user_id", sa.BigInteger(), nullable=True))
|
||||
if not _column_exists(bind, "tenants", "status"):
|
||||
op.add_column("tenants", sa.Column("status", sa.String(length=32), nullable=False, server_default="active"))
|
||||
|
||||
if not _table_exists(bind, "tenant_memberships"):
|
||||
op.create_table(
|
||||
"tenant_memberships",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, index=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("role", sa.String(length=50), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("is_personal_owner", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.UniqueConstraint("tenant_id", "user_id", name="uq_tenant_memberships_tenant_user"),
|
||||
)
|
||||
op.create_index("ix_tenant_memberships_tenant_status", "tenant_memberships", ["tenant_id", "status"], unique=False)
|
||||
|
||||
if not _table_exists(bind, "tenant_invitations"):
|
||||
op.create_table(
|
||||
"tenant_invitations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, index=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False, index=True),
|
||||
sa.Column("role", sa.String(length=50), nullable=False),
|
||||
sa.Column("project_ids", json_type, nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=128), nullable=False, unique=True, index=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("invited_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("accepted_by_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
)
|
||||
op.create_index("ix_tenant_invitations_tenant_status", "tenant_invitations", ["tenant_id", "status"], unique=False)
|
||||
|
||||
# Backfill current single-workspace users into membership rows.
|
||||
if _table_exists(bind, "users") and _table_exists(bind, "tenant_memberships"):
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
|
||||
SELECT users.tenant_id, users.id, users.role, 'active', false, NOW(), NOW()
|
||||
FROM users
|
||||
WHERE users.tenant_id IS NOT NULL
|
||||
ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO NOTHING
|
||||
"""
|
||||
)
|
||||
else:
|
||||
op.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
|
||||
SELECT users.tenant_id, users.id, users.role, 'active', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM users
|
||||
WHERE users.tenant_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "tenant_invitations"):
|
||||
if _index_exists(bind, "tenant_invitations", "ix_tenant_invitations_tenant_status"):
|
||||
op.drop_index("ix_tenant_invitations_tenant_status", table_name="tenant_invitations")
|
||||
op.drop_table("tenant_invitations")
|
||||
if _table_exists(bind, "tenant_memberships"):
|
||||
if _index_exists(bind, "tenant_memberships", "ix_tenant_memberships_tenant_status"):
|
||||
op.drop_index("ix_tenant_memberships_tenant_status", table_name="tenant_memberships")
|
||||
op.drop_table("tenant_memberships")
|
||||
if _table_exists(bind, "tenants"):
|
||||
for column_name in ("status", "owner_user_id", "slug", "workspace_type"):
|
||||
if _column_exists(bind, "tenants", column_name):
|
||||
op.drop_column("tenants", column_name)
|
||||
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
"""repair admin and yangyu workspace state
|
||||
|
||||
Revision ID: 20260428_repair_admin_yangyu_workspace_state
|
||||
Revises: 20260428_add_workspace_memberships
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260428_repair_admin_yangyu_workspace_state"
|
||||
down_revision = "20260428_add_workspace_memberships"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
return
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenants (id, name, domain, workspace_type, slug, owner_user_id, status, created_at, updated_at)
|
||||
SELECT
|
||||
1,
|
||||
'PMFlow 旧团队空间',
|
||||
NULL,
|
||||
'team',
|
||||
'legacy-team',
|
||||
admin_user.id,
|
||||
'active',
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM users AS admin_user
|
||||
WHERE admin_user.username = 'admin'
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET
|
||||
workspace_type = 'team',
|
||||
status = 'active',
|
||||
slug = COALESCE(tenants.slug, EXCLUDED.slug),
|
||||
owner_user_id = COALESCE(tenants.owner_user_id, EXCLUDED.owner_user_id),
|
||||
updated_at = NOW()
|
||||
"""
|
||||
)
|
||||
op.execute("SELECT setval('tenants_id_seq', GREATEST((SELECT COALESCE(MAX(id), 1) FROM tenants), 1), true)")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
|
||||
SELECT 1, users.id, 'OWNER', 'active', false, NOW(), NOW()
|
||||
FROM users
|
||||
WHERE users.username = 'admin'
|
||||
ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO UPDATE
|
||||
SET role = 'OWNER', status = 'active', is_personal_owner = false, updated_at = NOW()
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenant_memberships (tenant_id, user_id, role, status, is_personal_owner, created_at, updated_at)
|
||||
SELECT 1, users.id, 'BUILDER', 'active', false, NOW(), NOW()
|
||||
FROM users
|
||||
WHERE users.username = 'yangyu'
|
||||
ON CONFLICT ON CONSTRAINT uq_tenant_memberships_tenant_user DO UPDATE
|
||||
SET role = 'BUILDER', status = 'active', is_personal_owner = false, updated_at = NOW()
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
WITH personal_workspace AS (
|
||||
SELECT users.id AS user_id, tenant_memberships.tenant_id
|
||||
FROM users
|
||||
JOIN tenant_memberships ON tenant_memberships.user_id = users.id
|
||||
JOIN tenants ON tenants.id = tenant_memberships.tenant_id
|
||||
WHERE
|
||||
users.username = 'admin'
|
||||
AND tenants.workspace_type = 'personal'
|
||||
AND tenant_memberships.status = 'active'
|
||||
AND tenant_memberships.is_personal_owner IS TRUE
|
||||
ORDER BY tenants.id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE users
|
||||
SET tenant_id = personal_workspace.tenant_id, role = 'OWNER', updated_at = NOW()
|
||||
FROM personal_workspace
|
||||
WHERE users.id = personal_workspace.user_id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
WITH personal_workspace AS (
|
||||
SELECT users.id AS user_id, tenant_memberships.tenant_id
|
||||
FROM users
|
||||
JOIN tenant_memberships ON tenant_memberships.user_id = users.id
|
||||
JOIN tenants ON tenants.id = tenant_memberships.tenant_id
|
||||
WHERE
|
||||
users.username = 'yangyu'
|
||||
AND tenants.workspace_type = 'personal'
|
||||
AND tenant_memberships.status = 'active'
|
||||
AND tenant_memberships.is_personal_owner IS TRUE
|
||||
ORDER BY tenants.id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE users
|
||||
SET tenant_id = personal_workspace.tenant_id, role = 'OWNER', updated_at = NOW()
|
||||
FROM personal_workspace
|
||||
WHERE users.id = personal_workspace.user_id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Data repair is intentionally not reverted; rolling it back would reintroduce
|
||||
# the broken active workspace pointers.
|
||||
return
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"""add ai call log provider payloads
|
||||
|
||||
Revision ID: 20260429_add_ai_call_log_provider_payloads
|
||||
Revises: 20260429_add_prompt_release_checks
|
||||
Create Date: 2026-04-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260429_add_ai_call_log_provider_payloads"
|
||||
down_revision = "20260429_add_prompt_release_checks"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
for column_name in (
|
||||
"provider_request_payload",
|
||||
"provider_response_payload",
|
||||
"provider_attempts",
|
||||
):
|
||||
if not _column_exists(bind, table_name, column_name):
|
||||
op.add_column(table_name, sa.Column(column_name, _json_type(bind), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
for column_name in (
|
||||
"provider_attempts",
|
||||
"provider_response_payload",
|
||||
"provider_request_payload",
|
||||
):
|
||||
if _column_exists(bind, table_name, column_name):
|
||||
op.drop_column(table_name, column_name)
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"""add ai call trace fields
|
||||
|
||||
Revision ID: 20260429_add_ai_call_trace_fields
|
||||
Revises: 20260428_add_saved_idea_enrichment
|
||||
Create Date: 2026-04-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260429_add_ai_call_trace_fields"
|
||||
down_revision = "20260428_add_saved_idea_enrichment"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
columns = [
|
||||
("trace_id", sa.Column("trace_id", sa.String(length=80), nullable=True)),
|
||||
("provider", sa.Column("provider", sa.String(length=64), nullable=True)),
|
||||
("model", sa.Column("model", sa.String(length=120), nullable=True)),
|
||||
("latency_ms", sa.Column("latency_ms", sa.Integer(), nullable=True)),
|
||||
("routing_meta", sa.Column("routing_meta", _json_type(bind), nullable=True)),
|
||||
]
|
||||
for column_name, column in columns:
|
||||
if not _column_exists(bind, table_name, column_name):
|
||||
op.add_column(table_name, column)
|
||||
|
||||
existing_indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
for index_name, column_names in (
|
||||
("ix_ai_call_logs_trace_id", ["trace_id"]),
|
||||
("ix_ai_call_logs_provider", ["provider"]),
|
||||
("ix_ai_call_logs_model", ["model"]),
|
||||
):
|
||||
if index_name not in existing_indexes:
|
||||
op.create_index(index_name, table_name, column_names, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if not _table_exists(bind, table_name):
|
||||
return
|
||||
|
||||
existing_indexes = {index["name"] for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
for index_name in ("ix_ai_call_logs_model", "ix_ai_call_logs_provider", "ix_ai_call_logs_trace_id"):
|
||||
if index_name in existing_indexes:
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
for column_name in ("routing_meta", "latency_ms", "model", "provider", "trace_id"):
|
||||
if _column_exists(bind, table_name, column_name):
|
||||
op.drop_column(table_name, column_name)
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
"""add prompt quality linkage
|
||||
|
||||
Revision ID: 20260429_add_prompt_quality_linkage
|
||||
Revises: 20260429_add_prompt_release_checks
|
||||
Create Date: 2026-04-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260429_add_prompt_quality_linkage"
|
||||
down_revision = "20260429_add_prompt_release_checks"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "ai_call_logs"
|
||||
if _table_exists(bind, table_name):
|
||||
for column_name, column in (
|
||||
("prompt_key", sa.Column("prompt_key", sa.String(length=128), nullable=True)),
|
||||
("prompt_release_id", sa.Column("prompt_release_id", sa.Integer(), nullable=True)),
|
||||
("prompt_source", sa.Column("prompt_source", sa.String(length=32), nullable=True)),
|
||||
):
|
||||
if not _column_exists(bind, table_name, column_name):
|
||||
op.add_column(table_name, column)
|
||||
for index_name, column_names in (
|
||||
("ix_ai_call_logs_prompt_key", ["prompt_key"]),
|
||||
("ix_ai_call_logs_prompt_release_id", ["prompt_release_id"]),
|
||||
("ix_ai_call_logs_prompt_source", ["prompt_source"]),
|
||||
):
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, column_names, unique=False)
|
||||
|
||||
if not _table_exists(bind, "prompt_golden_cases"):
|
||||
op.create_table(
|
||||
"prompt_golden_cases",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("prompt_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("case_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=24), nullable=False, server_default="active"),
|
||||
sa.Column("input_payload", _json_type(bind), nullable=True),
|
||||
sa.Column("expected_contract", _json_type(bind), nullable=True),
|
||||
sa.Column("expected_contains", sa.Text(), nullable=True),
|
||||
sa.Column("source", sa.String(length=48), nullable=False, server_default="manual"),
|
||||
sa.Column("source_ai_call_log_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
for index_name, column_names in (
|
||||
("ix_prompt_golden_cases_tenant_id", ["tenant_id"]),
|
||||
("ix_prompt_golden_cases_prompt_key", ["prompt_key"]),
|
||||
("ix_prompt_golden_cases_status", ["status"]),
|
||||
("ix_prompt_golden_cases_source", ["source"]),
|
||||
("ix_prompt_golden_cases_source_ai_call_log_id", ["source_ai_call_log_id"]),
|
||||
("ix_prompt_golden_cases_created_by", ["created_by"]),
|
||||
):
|
||||
op.create_index(index_name, "prompt_golden_cases", column_names, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "prompt_golden_cases"):
|
||||
for index_name in (
|
||||
"ix_prompt_golden_cases_created_by",
|
||||
"ix_prompt_golden_cases_source_ai_call_log_id",
|
||||
"ix_prompt_golden_cases_source",
|
||||
"ix_prompt_golden_cases_status",
|
||||
"ix_prompt_golden_cases_prompt_key",
|
||||
"ix_prompt_golden_cases_tenant_id",
|
||||
):
|
||||
if _index_exists(bind, "prompt_golden_cases", index_name):
|
||||
op.drop_index(index_name, table_name="prompt_golden_cases")
|
||||
op.drop_table("prompt_golden_cases")
|
||||
|
||||
table_name = "ai_call_logs"
|
||||
if _table_exists(bind, table_name):
|
||||
for index_name in (
|
||||
"ix_ai_call_logs_prompt_source",
|
||||
"ix_ai_call_logs_prompt_release_id",
|
||||
"ix_ai_call_logs_prompt_key",
|
||||
):
|
||||
if _index_exists(bind, table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
for column_name in ("prompt_source", "prompt_release_id", "prompt_key"):
|
||||
if _column_exists(bind, table_name, column_name):
|
||||
op.drop_column(table_name, column_name)
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
"""add prompt release checks
|
||||
|
||||
Revision ID: 20260429_add_prompt_release_checks
|
||||
Revises: 20260429_rename_glm_provider_to_dashscope
|
||||
Create Date: 2026-04-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260429_add_prompt_release_checks"
|
||||
down_revision = "20260429_rename_glm_provider_to_dashscope"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "prompt_release_checks"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"prompt_release_checks",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("prompt_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("release_id", sa.Integer(), nullable=False),
|
||||
sa.Column("check_type", sa.String(length=48), nullable=False),
|
||||
sa.Column("status", sa.String(length=24), nullable=False),
|
||||
sa.Column("severity", sa.String(length=16), nullable=False, server_default="info"),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("details_json", _json_type(bind), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("completed_at", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["release_id"], ["prompt_releases.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_prompt_release_checks_tenant_id", "prompt_release_checks", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_prompt_key", "prompt_release_checks", ["prompt_key"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_release_id", "prompt_release_checks", ["release_id"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_check_type", "prompt_release_checks", ["check_type"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_status", "prompt_release_checks", ["status"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_severity", "prompt_release_checks", ["severity"], unique=False)
|
||||
op.create_index("ix_prompt_release_checks_created_by", "prompt_release_checks", ["created_by"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_release_checks"):
|
||||
return
|
||||
for index_name in (
|
||||
"ix_prompt_release_checks_created_by",
|
||||
"ix_prompt_release_checks_severity",
|
||||
"ix_prompt_release_checks_status",
|
||||
"ix_prompt_release_checks_check_type",
|
||||
"ix_prompt_release_checks_release_id",
|
||||
"ix_prompt_release_checks_prompt_key",
|
||||
"ix_prompt_release_checks_tenant_id",
|
||||
):
|
||||
op.drop_index(index_name, table_name="prompt_release_checks")
|
||||
op.drop_table("prompt_release_checks")
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
"""rename glm provider code to dashscope
|
||||
|
||||
Revision ID: 20260429_rename_glm_provider_to_dashscope
|
||||
Revises: 20260429_add_ai_call_trace_fields
|
||||
Create Date: 2026-04-29
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260429_rename_glm_provider_to_dashscope"
|
||||
down_revision = "20260429_add_ai_call_trace_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _load_json(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _replace_provider_code(value: Any) -> tuple[Any, bool]:
|
||||
changed = False
|
||||
if isinstance(value, dict):
|
||||
result: dict[Any, Any] = {}
|
||||
for raw_key, raw_value in value.items():
|
||||
key = "dashscope" if raw_key == "glm" else raw_key
|
||||
next_value, child_changed = _replace_provider_code(raw_value)
|
||||
result[key] = next_value
|
||||
changed = changed or child_changed or key != raw_key
|
||||
return result, changed
|
||||
if isinstance(value, list):
|
||||
result = []
|
||||
for item in value:
|
||||
next_value, child_changed = _replace_provider_code(item)
|
||||
result.append(next_value)
|
||||
changed = changed or child_changed
|
||||
return result, changed
|
||||
if value == "glm":
|
||||
return "dashscope", True
|
||||
return value, False
|
||||
|
||||
|
||||
def _migrate_json_table(bind, table_name: str, column_name: str) -> None:
|
||||
if not _table_exists(bind, table_name) or not _column_exists(bind, table_name, column_name):
|
||||
return
|
||||
table = sa.table(
|
||||
table_name,
|
||||
sa.column("id", sa.Integer()),
|
||||
sa.column(column_name, _json_type(bind)),
|
||||
)
|
||||
rows = bind.execute(sa.select(table.c.id, table.c[column_name])).all()
|
||||
for row_id, raw_value in rows:
|
||||
payload = _load_json(raw_value)
|
||||
next_payload, changed = _replace_provider_code(payload)
|
||||
if changed:
|
||||
bind.execute(
|
||||
table.update().where(table.c.id == row_id).values({column_name: next_payload})
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
_migrate_json_table(bind, "integrations", "config")
|
||||
_migrate_json_table(bind, "ai_call_logs", "routing_meta")
|
||||
|
||||
if _table_exists(bind, "ai_call_logs") and _column_exists(bind, "ai_call_logs", "provider"):
|
||||
op.execute("UPDATE ai_call_logs SET provider = 'dashscope' WHERE provider = 'glm'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Intentionally irreversible: glm was a misleading historical provider code.
|
||||
pass
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
"""add prompt replay runs
|
||||
|
||||
Revision ID: 20260430_add_prompt_replay_runs
|
||||
Revises: 20260429_add_prompt_quality_linkage
|
||||
Create Date: 2026-04-30
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260430_add_prompt_replay_runs"
|
||||
down_revision = "20260429_add_prompt_quality_linkage"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_replay_runs"):
|
||||
op.create_table(
|
||||
"prompt_replay_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("prompt_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("release_id", sa.Integer(), nullable=False),
|
||||
sa.Column("baseline_release_id", sa.Integer(), nullable=True),
|
||||
sa.Column("run_status", sa.String(length=24), nullable=False, server_default="pending"),
|
||||
sa.Column("run_mode", sa.String(length=32), nullable=False, server_default="contract_smoke"),
|
||||
sa.Column("case_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("passed_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("failed_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("warning_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("score_delta", sa.Float(), nullable=True),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("risk_flags_json", _json_type(bind), nullable=True),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("started_at", sa.String(length=64), nullable=True),
|
||||
sa.Column("completed_at", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["release_id"], ["prompt_releases.id"], ondelete="CASCADE"),
|
||||
)
|
||||
for index_name, column_names in (
|
||||
("ix_prompt_replay_runs_tenant_id", ["tenant_id"]),
|
||||
("ix_prompt_replay_runs_prompt_key", ["prompt_key"]),
|
||||
("ix_prompt_replay_runs_release_id", ["release_id"]),
|
||||
("ix_prompt_replay_runs_baseline_release_id", ["baseline_release_id"]),
|
||||
("ix_prompt_replay_runs_run_status", ["run_status"]),
|
||||
("ix_prompt_replay_runs_run_mode", ["run_mode"]),
|
||||
("ix_prompt_replay_runs_created_by", ["created_by"]),
|
||||
):
|
||||
op.create_index(index_name, "prompt_replay_runs", column_names, unique=False)
|
||||
|
||||
if not _table_exists(bind, "prompt_replay_case_results"):
|
||||
op.create_table(
|
||||
"prompt_replay_case_results",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("run_id", sa.Integer(), nullable=False),
|
||||
sa.Column("golden_case_id", sa.Integer(), nullable=False),
|
||||
sa.Column("prompt_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("release_id", sa.Integer(), nullable=False),
|
||||
sa.Column("baseline_output", sa.Text(), nullable=True),
|
||||
sa.Column("candidate_output", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), nullable=False),
|
||||
sa.Column("contract_result_json", _json_type(bind), nullable=True),
|
||||
sa.Column("diff_summary", sa.Text(), nullable=True),
|
||||
sa.Column("risk_flags_json", _json_type(bind), nullable=True),
|
||||
sa.Column("latency_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("estimated_cost", sa.Float(), nullable=True),
|
||||
sa.Column("error_summary", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["run_id"], ["prompt_replay_runs.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["golden_case_id"], ["prompt_golden_cases.id"], ondelete="CASCADE"),
|
||||
)
|
||||
for index_name, column_names in (
|
||||
("ix_prompt_replay_case_results_tenant_id", ["tenant_id"]),
|
||||
("ix_prompt_replay_case_results_run_id", ["run_id"]),
|
||||
("ix_prompt_replay_case_results_golden_case_id", ["golden_case_id"]),
|
||||
("ix_prompt_replay_case_results_prompt_key", ["prompt_key"]),
|
||||
("ix_prompt_replay_case_results_release_id", ["release_id"]),
|
||||
("ix_prompt_replay_case_results_status", ["status"]),
|
||||
):
|
||||
op.create_index(index_name, "prompt_replay_case_results", column_names, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "prompt_replay_case_results"):
|
||||
for index_name in (
|
||||
"ix_prompt_replay_case_results_status",
|
||||
"ix_prompt_replay_case_results_release_id",
|
||||
"ix_prompt_replay_case_results_prompt_key",
|
||||
"ix_prompt_replay_case_results_golden_case_id",
|
||||
"ix_prompt_replay_case_results_run_id",
|
||||
"ix_prompt_replay_case_results_tenant_id",
|
||||
):
|
||||
if _index_exists(bind, "prompt_replay_case_results", index_name):
|
||||
op.drop_index(index_name, table_name="prompt_replay_case_results")
|
||||
op.drop_table("prompt_replay_case_results")
|
||||
if _table_exists(bind, "prompt_replay_runs"):
|
||||
for index_name in (
|
||||
"ix_prompt_replay_runs_created_by",
|
||||
"ix_prompt_replay_runs_run_mode",
|
||||
"ix_prompt_replay_runs_run_status",
|
||||
"ix_prompt_replay_runs_baseline_release_id",
|
||||
"ix_prompt_replay_runs_release_id",
|
||||
"ix_prompt_replay_runs_prompt_key",
|
||||
"ix_prompt_replay_runs_tenant_id",
|
||||
):
|
||||
if _index_exists(bind, "prompt_replay_runs", index_name):
|
||||
op.drop_index(index_name, table_name="prompt_replay_runs")
|
||||
op.drop_table("prompt_replay_runs")
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"""extend prompt eval replay
|
||||
|
||||
Revision ID: 20260430_extend_prompt_eval_replay
|
||||
Revises: 20260430_add_prompt_replay_runs
|
||||
Create Date: 2026-04-30
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260430_extend_prompt_eval_replay"
|
||||
down_revision = "20260430_add_prompt_replay_runs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return column_name in {str(column.get("name") or "") for column in sa.inspect(bind).get_columns(table_name)}
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return index_name in {str(index.get("name") or "") for index in sa.inspect(bind).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None:
|
||||
if not _column_exists(bind, table_name, str(column.name)):
|
||||
op.add_column(table_name, column)
|
||||
|
||||
|
||||
def _create_index_if_missing(bind, index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns, unique=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "prompt_golden_cases"):
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("case_type", sa.String(length=32), nullable=False, server_default="regression"))
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("priority", sa.String(length=24), nullable=False, server_default="blocking"))
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("scenario_key", sa.String(length=128), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("tags", _json_type(bind), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("last_run_status", sa.String(length=24), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_golden_cases", sa.Column("last_failed_at", sa.String(length=64), nullable=True))
|
||||
_create_index_if_missing(bind, "ix_prompt_golden_cases_case_type", "prompt_golden_cases", ["case_type"])
|
||||
_create_index_if_missing(bind, "ix_prompt_golden_cases_priority", "prompt_golden_cases", ["priority"])
|
||||
_create_index_if_missing(bind, "ix_prompt_golden_cases_scenario_key", "prompt_golden_cases", ["scenario_key"])
|
||||
_create_index_if_missing(bind, "ix_prompt_golden_cases_last_run_status", "prompt_golden_cases", ["last_run_status"])
|
||||
|
||||
if _table_exists(bind, "prompt_replay_case_results"):
|
||||
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("baseline_ai_call_log_id", sa.Integer(), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("candidate_ai_call_log_id", sa.Integer(), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_score", sa.Float(), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_label", sa.String(length=32), nullable=True))
|
||||
_add_column_if_missing(bind, "prompt_replay_case_results", sa.Column("judge_reason", sa.Text(), nullable=True))
|
||||
_create_index_if_missing(bind, "ix_prompt_replay_case_results_judge_label", "prompt_replay_case_results", ["judge_label"])
|
||||
_create_index_if_missing(bind, "ix_prompt_replay_case_results_baseline_ai_call_log_id", "prompt_replay_case_results", ["baseline_ai_call_log_id"])
|
||||
_create_index_if_missing(bind, "ix_prompt_replay_case_results_candidate_ai_call_log_id", "prompt_replay_case_results", ["candidate_ai_call_log_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "prompt_replay_case_results"):
|
||||
for index_name in (
|
||||
"ix_prompt_replay_case_results_candidate_ai_call_log_id",
|
||||
"ix_prompt_replay_case_results_baseline_ai_call_log_id",
|
||||
"ix_prompt_replay_case_results_judge_label",
|
||||
):
|
||||
if _index_exists(bind, "prompt_replay_case_results", index_name):
|
||||
op.drop_index(index_name, table_name="prompt_replay_case_results")
|
||||
for column_name in ("judge_reason", "judge_label", "judge_score", "candidate_ai_call_log_id", "baseline_ai_call_log_id"):
|
||||
if _column_exists(bind, "prompt_replay_case_results", column_name):
|
||||
op.drop_column("prompt_replay_case_results", column_name)
|
||||
if _table_exists(bind, "prompt_golden_cases"):
|
||||
for index_name in (
|
||||
"ix_prompt_golden_cases_last_run_status",
|
||||
"ix_prompt_golden_cases_scenario_key",
|
||||
"ix_prompt_golden_cases_priority",
|
||||
"ix_prompt_golden_cases_case_type",
|
||||
):
|
||||
if _index_exists(bind, "prompt_golden_cases", index_name):
|
||||
op.drop_index(index_name, table_name="prompt_golden_cases")
|
||||
for column_name in ("last_failed_at", "last_run_status", "tags", "scenario_key", "priority", "case_type"):
|
||||
if _column_exists(bind, "prompt_golden_cases", column_name):
|
||||
op.drop_column("prompt_golden_cases", column_name)
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
"""add pmflow launch draft jobs
|
||||
|
||||
Revision ID: 20260501_add_pmflow_launch_draft_jobs
|
||||
Revises: 20260430_extend_prompt_eval_replay
|
||||
Create Date: 2026-05-01
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260501_add_pmflow_launch_draft_jobs"
|
||||
down_revision = "20260430_extend_prompt_eval_replay"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "pmflow_launch_draft_jobs"):
|
||||
return
|
||||
|
||||
json_type = _json_type(bind)
|
||||
op.create_table(
|
||||
"pmflow_launch_draft_jobs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("job_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("revision_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("parent_draft_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("superseded_by_job_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("result_draft_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("input_text", sa.Text(), nullable=False),
|
||||
sa.Column("input_type", sa.String(length=32), nullable=False, server_default="text"),
|
||||
sa.Column("source", sa.String(length=32), nullable=False, server_default="web"),
|
||||
sa.Column("attachments", json_type, nullable=False),
|
||||
sa.Column("mode", sa.String(length=32), nullable=False, server_default="create"),
|
||||
sa.Column("revision_note", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="queued"),
|
||||
sa.Column("result_payload", json_type, nullable=True),
|
||||
sa.Column("changed_fields", json_type, nullable=False),
|
||||
sa.Column("error_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.UniqueConstraint("tenant_id", "job_id", name="uq_pmflow_launch_draft_jobs_tenant_job_id"),
|
||||
sa.UniqueConstraint("tenant_id", "revision_id", name="uq_pmflow_launch_draft_jobs_tenant_revision_id"),
|
||||
)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_tenant_id", "pmflow_launch_draft_jobs", ["tenant_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_user_id", "pmflow_launch_draft_jobs", ["user_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_job_id", "pmflow_launch_draft_jobs", ["job_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_revision_id", "pmflow_launch_draft_jobs", ["revision_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_parent_draft_id", "pmflow_launch_draft_jobs", ["parent_draft_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_superseded_by_job_id", "pmflow_launch_draft_jobs", ["superseded_by_job_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_result_draft_id", "pmflow_launch_draft_jobs", ["result_draft_id"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_status", "pmflow_launch_draft_jobs", ["status"], unique=False)
|
||||
op.create_index("ix_pmflow_launch_draft_jobs_expires_at", "pmflow_launch_draft_jobs", ["expires_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "pmflow_launch_draft_jobs"):
|
||||
return
|
||||
for index_name in (
|
||||
"ix_pmflow_launch_draft_jobs_expires_at",
|
||||
"ix_pmflow_launch_draft_jobs_status",
|
||||
"ix_pmflow_launch_draft_jobs_result_draft_id",
|
||||
"ix_pmflow_launch_draft_jobs_superseded_by_job_id",
|
||||
"ix_pmflow_launch_draft_jobs_parent_draft_id",
|
||||
"ix_pmflow_launch_draft_jobs_revision_id",
|
||||
"ix_pmflow_launch_draft_jobs_job_id",
|
||||
"ix_pmflow_launch_draft_jobs_user_id",
|
||||
"ix_pmflow_launch_draft_jobs_tenant_id",
|
||||
):
|
||||
op.drop_index(index_name, table_name="pmflow_launch_draft_jobs")
|
||||
op.drop_table("pmflow_launch_draft_jobs")
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
"""add pmflow ai feedback events
|
||||
|
||||
Revision ID: 20260503_add_pmflow_ai_feedback_events
|
||||
Revises: 20260501_add_pmflow_launch_draft_jobs
|
||||
Create Date: 2026-05-03
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260503_add_pmflow_ai_feedback_events"
|
||||
down_revision = "20260501_add_pmflow_launch_draft_jobs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _table_exists(bind, "pmflow_ai_feedback_events"):
|
||||
return
|
||||
|
||||
json_type = _json_type(bind)
|
||||
op.create_table(
|
||||
"pmflow_ai_feedback_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), sa.ForeignKey("projects.id"), nullable=False),
|
||||
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_threads.id"), nullable=True),
|
||||
sa.Column("message_id", sa.BigInteger(), sa.ForeignKey("pmflow_thread_messages.id"), nullable=True),
|
||||
sa.Column("change_set_id", sa.BigInteger(), sa.ForeignKey("pmflow_change_sets.id"), nullable=True),
|
||||
sa.Column("ai_call_log_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("actor_user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("target_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("target_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("answer_kind", sa.String(length=64), nullable=False, server_default="chat_answer"),
|
||||
sa.Column("rating", sa.String(length=16), nullable=False),
|
||||
sa.Column("reason_tags", json_type, nullable=False),
|
||||
sa.Column("comment", sa.Text(), nullable=True),
|
||||
sa.Column("scene_key", sa.String(length=128), nullable=True),
|
||||
sa.Column("prompt_key", sa.String(length=128), nullable=True),
|
||||
sa.Column("prompt_release_id", sa.Integer(), nullable=True),
|
||||
sa.Column("runtime_profile_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("model_provider", sa.String(length=64), nullable=True),
|
||||
sa.Column("model_name", sa.String(length=120), nullable=True),
|
||||
sa.Column("feedback_metadata", json_type, nullable=False),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"project_id",
|
||||
"actor_user_id",
|
||||
"target_type",
|
||||
"target_id",
|
||||
name="uq_pmflow_ai_feedback_target_user",
|
||||
),
|
||||
)
|
||||
for column_name in (
|
||||
"tenant_id",
|
||||
"project_id",
|
||||
"thread_id",
|
||||
"message_id",
|
||||
"change_set_id",
|
||||
"ai_call_log_id",
|
||||
"actor_user_id",
|
||||
"target_type",
|
||||
"target_id",
|
||||
"answer_kind",
|
||||
"rating",
|
||||
"scene_key",
|
||||
"prompt_key",
|
||||
"prompt_release_id",
|
||||
"runtime_profile_id",
|
||||
):
|
||||
op.create_index(
|
||||
f"ix_pmflow_ai_feedback_events_{column_name}",
|
||||
"pmflow_ai_feedback_events",
|
||||
[column_name],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "pmflow_ai_feedback_events"):
|
||||
return
|
||||
for column_name in (
|
||||
"runtime_profile_id",
|
||||
"prompt_release_id",
|
||||
"prompt_key",
|
||||
"scene_key",
|
||||
"rating",
|
||||
"answer_kind",
|
||||
"target_id",
|
||||
"target_type",
|
||||
"actor_user_id",
|
||||
"ai_call_log_id",
|
||||
"change_set_id",
|
||||
"message_id",
|
||||
"thread_id",
|
||||
"project_id",
|
||||
"tenant_id",
|
||||
):
|
||||
op.drop_index(f"ix_pmflow_ai_feedback_events_{column_name}", table_name="pmflow_ai_feedback_events")
|
||||
op.drop_table("pmflow_ai_feedback_events")
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""add pmflow changeset committer
|
||||
|
||||
Revision ID: 20260505_add_pmflow_changeset_committer
|
||||
Revises: 20260503_add_pmflow_ai_feedback_events
|
||||
Create Date: 2026-05-05
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260505_add_pmflow_changeset_committer"
|
||||
down_revision = "20260503_add_pmflow_ai_feedback_events"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not sa.inspect(bind).has_table(table_name):
|
||||
return False
|
||||
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not sa.inspect(bind).has_table(table_name):
|
||||
return False
|
||||
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _column_exists(bind, "pmflow_change_sets", "committed_by"):
|
||||
op.add_column("pmflow_change_sets", sa.Column("committed_by", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True))
|
||||
if not _index_exists(bind, "pmflow_change_sets", "ix_pmflow_change_sets_committed_by"):
|
||||
op.create_index("ix_pmflow_change_sets_committed_by", "pmflow_change_sets", ["committed_by"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _index_exists(bind, "pmflow_change_sets", "ix_pmflow_change_sets_committed_by"):
|
||||
op.drop_index("ix_pmflow_change_sets_committed_by", table_name="pmflow_change_sets")
|
||||
if _column_exists(bind, "pmflow_change_sets", "committed_by"):
|
||||
op.drop_column("pmflow_change_sets", "committed_by")
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"""add execution runtime credentials
|
||||
|
||||
Revision ID: 20260506_add_execution_runtime_credentials
|
||||
Revises: 20260505_add_pmflow_changeset_committer
|
||||
Create Date: 2026-05-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260506_add_execution_runtime_credentials"
|
||||
down_revision = "20260505_add_pmflow_changeset_committer"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(index["name"] == index_name for index in sa.inspect(bind).get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "pmflow_execution_runtime_credentials"
|
||||
if not _table_exists(bind, table_name):
|
||||
op.create_table(
|
||||
table_name,
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("key_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("secret_hash", sa.String(length=128), nullable=False),
|
||||
sa.Column("project_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("prompt_package_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("repo_binding_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("runtime_target", sa.String(length=32), nullable=False),
|
||||
sa.Column("allowed_sources", _json_type(bind), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("expires_at", sa.TIMESTAMP(), nullable=False),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("last_used_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("use_count", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||
sa.ForeignKeyConstraint(["prompt_package_id"], ["prompt_packages.id"]),
|
||||
sa.ForeignKeyConstraint(["repo_binding_id"], ["repo_bindings.id"]),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "key_id", name="uq_pmflow_runtime_credentials_tenant_key"),
|
||||
)
|
||||
|
||||
for column_name in (
|
||||
"tenant_id",
|
||||
"key_id",
|
||||
"project_id",
|
||||
"task_id",
|
||||
"prompt_package_id",
|
||||
"repo_binding_id",
|
||||
"runtime_target",
|
||||
"status",
|
||||
"expires_at",
|
||||
"created_by",
|
||||
):
|
||||
index_name = f"ix_{table_name}_{column_name}"
|
||||
if not _index_exists(bind, table_name, index_name):
|
||||
op.create_index(index_name, table_name, [column_name], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
table_name = "pmflow_execution_runtime_credentials"
|
||||
if _table_exists(bind, table_name):
|
||||
op.drop_table(table_name)
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""add qa context workspaces
|
||||
|
||||
Revision ID: 20260507_add_qa_context_workspaces
|
||||
Revises: 20260506_add_execution_runtime_credentials
|
||||
Create Date: 2026-05-07
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
except Exception: # pragma: no cover
|
||||
JSONB = sa.JSON
|
||||
|
||||
|
||||
revision = "20260507_add_qa_context_workspaces"
|
||||
down_revision = "20260506_add_execution_runtime_credentials"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type(bind):
|
||||
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(bind, table_name):
|
||||
return False
|
||||
return any(column["name"] == column_name for column in sa.inspect(bind).get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "qa_session_states"):
|
||||
return
|
||||
with op.batch_alter_table("qa_session_states") as batch_op:
|
||||
if not _column_exists(bind, "qa_session_states", "active_workspace"):
|
||||
batch_op.add_column(sa.Column("active_workspace", _json_type(bind), nullable=True))
|
||||
if not _column_exists(bind, "qa_session_states", "context_capsules"):
|
||||
batch_op.add_column(sa.Column("context_capsules", _json_type(bind), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "qa_session_states"):
|
||||
return
|
||||
with op.batch_alter_table("qa_session_states") as batch_op:
|
||||
if _column_exists(bind, "qa_session_states", "context_capsules"):
|
||||
batch_op.drop_column("context_capsules")
|
||||
if _column_exists(bind, "qa_session_states", "active_workspace"):
|
||||
batch_op.drop_column("active_workspace")
|
||||
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
"""seed turn runtime prompt releases
|
||||
|
||||
Revision ID: 20260511_seed_turn_runtime_prompt_releases
|
||||
Revises: 20260507_add_qa_context_workspaces
|
||||
Create Date: 2026-05-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260511_seed_turn_runtime_prompt_releases"
|
||||
down_revision = "20260507_add_qa_context_workspaces"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"PROMPT_PMFLOW_AI_TURN_GOAL_STATE_ASSESSOR",
|
||||
"PROMPT_PMFLOW_AI_TURN_STATE_ANSWER_SYNTHESIZER",
|
||||
)
|
||||
SEED_NOTES = "Seeded by 20260511_seed_turn_runtime_prompt_releases"
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _prompt_body(prompt_key: str) -> str:
|
||||
from app.config import ai_prompts
|
||||
|
||||
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
|
||||
|
||||
|
||||
def _seed_active_release(bind, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
|
||||
active = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id FROM prompt_releases
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND prompt_key = :prompt_key
|
||||
AND status = 'active'
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "prompt_key": prompt_key},
|
||||
).first()
|
||||
if active is not None:
|
||||
return
|
||||
|
||||
max_version = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT MAX(version) FROM prompt_releases
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND prompt_key = :prompt_key
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "prompt_key": prompt_key},
|
||||
).scalar()
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO prompt_releases
|
||||
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
|
||||
VALUES
|
||||
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"prompt_key": prompt_key,
|
||||
"version": int(max_version or 0) + 1,
|
||||
"body_snapshot": body_snapshot,
|
||||
"notes": SEED_NOTES,
|
||||
"activated_at": "2026-05-11T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_releases"):
|
||||
return
|
||||
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
for prompt_key in PROMPT_KEYS:
|
||||
body_snapshot = _prompt_body(prompt_key)
|
||||
if body_snapshot:
|
||||
_seed_active_release(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_releases"):
|
||||
return
|
||||
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM prompt_releases
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND prompt_key IN :prompt_keys
|
||||
AND notes = :notes
|
||||
"""
|
||||
).bindparams(sa.bindparam("prompt_keys", expanding=True)),
|
||||
{"tenant_id": tenant_id, "prompt_keys": PROMPT_KEYS, "notes": SEED_NOTES},
|
||||
)
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
"""add durable pmflow agent runtime records
|
||||
|
||||
Revision ID: 20260513_add_pmflow_agent_runtime
|
||||
Revises: 20260511_seed_turn_runtime_prompt_releases
|
||||
Create Date: 2026-05-13
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260513_add_pmflow_agent_runtime"
|
||||
down_revision = "20260511_seed_turn_runtime_prompt_releases"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type():
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
return postgresql.JSONB()
|
||||
return sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return sa.inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
return any(item.get("name") == index_name for item in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if _table_exists(table_name) and not _index_exists(table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def _drop_table_once(table_name: str) -> None:
|
||||
if _table_exists(table_name):
|
||||
op.drop_table(table_name)
|
||||
|
||||
|
||||
def _base_columns() -> list[sa.Column]:
|
||||
return [
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
json_type = _json_type()
|
||||
|
||||
if not _table_exists("pmflow_agent_runs"):
|
||||
op.create_table(
|
||||
"pmflow_agent_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("public_run_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("qa_session_id", sa.BigInteger(), sa.ForeignKey("qa_sessions.id"), nullable=True),
|
||||
sa.Column("workspace_scope", sa.String(length=80), nullable=True),
|
||||
sa.Column("surface", sa.String(length=80), nullable=True),
|
||||
sa.Column("presentation_mode", sa.String(length=40), nullable=True),
|
||||
sa.Column("source_channel", sa.String(length=40), nullable=True),
|
||||
sa.Column("source_ref", sa.String(length=160), nullable=True),
|
||||
sa.Column("title", sa.String(length=240), nullable=True),
|
||||
sa.Column("goal", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("active_object_refs", json_type, nullable=True),
|
||||
sa.Column("current_step_id", sa.String(length=80), nullable=True),
|
||||
sa.Column("final_answer", sa.Text(), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("run_metadata", json_type, nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint("tenant_id", "public_run_id", name="uq_pmflow_agent_runs_tenant_public_run_id"),
|
||||
)
|
||||
_create_index_once("ix_pmflow_agent_runs_tenant_id", "pmflow_agent_runs", ["tenant_id"])
|
||||
_create_index_once("ix_pmflow_agent_runs_public_run_id", "pmflow_agent_runs", ["public_run_id"])
|
||||
_create_index_once("ix_pmflow_agent_runs_user_id", "pmflow_agent_runs", ["user_id"])
|
||||
_create_index_once("ix_pmflow_agent_runs_qa_session_id", "pmflow_agent_runs", ["qa_session_id"])
|
||||
_create_index_once("ix_pmflow_agent_runs_status", "pmflow_agent_runs", ["status"])
|
||||
_create_index_once("ix_pmflow_agent_runs_started_at", "pmflow_agent_runs", ["started_at"])
|
||||
|
||||
if not _table_exists("pmflow_agent_steps"):
|
||||
op.create_table(
|
||||
"pmflow_agent_steps",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"run_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("pmflow_agent_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("public_step_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("step_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("step_kind", sa.String(length=64), nullable=False, server_default="runtime_step"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="completed"),
|
||||
sa.Column("input_summary", sa.Text(), nullable=True),
|
||||
sa.Column("output_summary", sa.Text(), nullable=True),
|
||||
sa.Column("tool_name", sa.String(length=120), nullable=True),
|
||||
sa.Column("duration_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("step_payload", json_type, nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint("tenant_id", "run_id", "public_step_id", name="uq_pmflow_agent_steps_tenant_run_public_step"),
|
||||
)
|
||||
_create_index_once("ix_pmflow_agent_steps_tenant_id", "pmflow_agent_steps", ["tenant_id"])
|
||||
_create_index_once("ix_pmflow_agent_steps_run_id", "pmflow_agent_steps", ["run_id"])
|
||||
_create_index_once("ix_pmflow_agent_steps_public_step_id", "pmflow_agent_steps", ["public_step_id"])
|
||||
_create_index_once("ix_pmflow_agent_steps_step_index", "pmflow_agent_steps", ["step_index"])
|
||||
_create_index_once("ix_pmflow_agent_steps_step_kind", "pmflow_agent_steps", ["step_kind"])
|
||||
_create_index_once("ix_pmflow_agent_steps_status", "pmflow_agent_steps", ["status"])
|
||||
_create_index_once("ix_pmflow_agent_steps_tool_name", "pmflow_agent_steps", ["tool_name"])
|
||||
|
||||
if not _table_exists("pmflow_agent_observations"):
|
||||
op.create_table(
|
||||
"pmflow_agent_observations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"run_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("pmflow_agent_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("step_id", sa.BigInteger(), sa.ForeignKey("pmflow_agent_steps.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("public_observation_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("source_kind", sa.String(length=80), nullable=False, server_default="agent_runtime"),
|
||||
sa.Column("customer_safe_summary", sa.Text(), nullable=True),
|
||||
sa.Column("confidence", sa.String(length=32), nullable=True),
|
||||
sa.Column("facts", json_type, nullable=True),
|
||||
sa.Column("gaps", json_type, nullable=True),
|
||||
sa.Column("raw_payload", json_type, nullable=True),
|
||||
sa.Column("observed_at", sa.TIMESTAMP(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"run_id",
|
||||
"public_observation_id",
|
||||
name="uq_pmflow_agent_observations_tenant_run_public_observation",
|
||||
),
|
||||
)
|
||||
_create_index_once("ix_pmflow_agent_observations_tenant_id", "pmflow_agent_observations", ["tenant_id"])
|
||||
_create_index_once("ix_pmflow_agent_observations_run_id", "pmflow_agent_observations", ["run_id"])
|
||||
_create_index_once("ix_pmflow_agent_observations_step_id", "pmflow_agent_observations", ["step_id"])
|
||||
_create_index_once("ix_pmflow_agent_observations_public_observation_id", "pmflow_agent_observations", ["public_observation_id"])
|
||||
_create_index_once("ix_pmflow_agent_observations_source_kind", "pmflow_agent_observations", ["source_kind"])
|
||||
_create_index_once("ix_pmflow_agent_observations_observed_at", "pmflow_agent_observations", ["observed_at"])
|
||||
|
||||
if not _table_exists("pmflow_agent_artifacts"):
|
||||
op.create_table(
|
||||
"pmflow_agent_artifacts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"run_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("pmflow_agent_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("step_id", sa.BigInteger(), sa.ForeignKey("pmflow_agent_steps.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("public_artifact_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("artifact_kind", sa.String(length=80), nullable=False),
|
||||
sa.Column("title", sa.String(length=240), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="created"),
|
||||
sa.Column("payload", json_type, nullable=True),
|
||||
sa.Column("customer_safe_payload", json_type, nullable=True),
|
||||
sa.Column("created_by_step_id", sa.String(length=80), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint("tenant_id", "run_id", "public_artifact_id", name="uq_pmflow_agent_artifacts_tenant_run_public_artifact"),
|
||||
)
|
||||
_create_index_once("ix_pmflow_agent_artifacts_tenant_id", "pmflow_agent_artifacts", ["tenant_id"])
|
||||
_create_index_once("ix_pmflow_agent_artifacts_run_id", "pmflow_agent_artifacts", ["run_id"])
|
||||
_create_index_once("ix_pmflow_agent_artifacts_step_id", "pmflow_agent_artifacts", ["step_id"])
|
||||
_create_index_once("ix_pmflow_agent_artifacts_public_artifact_id", "pmflow_agent_artifacts", ["public_artifact_id"])
|
||||
_create_index_once("ix_pmflow_agent_artifacts_artifact_kind", "pmflow_agent_artifacts", ["artifact_kind"])
|
||||
_create_index_once("ix_pmflow_agent_artifacts_status", "pmflow_agent_artifacts", ["status"])
|
||||
_create_index_once("ix_pmflow_agent_artifacts_created_by_step_id", "pmflow_agent_artifacts", ["created_by_step_id"])
|
||||
|
||||
if not _table_exists("pmflow_agent_verifications"):
|
||||
op.create_table(
|
||||
"pmflow_agent_verifications",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"run_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("pmflow_agent_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("step_id", sa.BigInteger(), sa.ForeignKey("pmflow_agent_steps.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("public_verification_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("target_ref", sa.String(length=160), nullable=True),
|
||||
sa.Column("result", sa.String(length=32), nullable=False, server_default="unknown"),
|
||||
sa.Column("retry_would_change_state", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("findings", json_type, nullable=True),
|
||||
sa.Column("verification_payload", json_type, nullable=True),
|
||||
sa.Column("verified_at", sa.TIMESTAMP(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"run_id",
|
||||
"public_verification_id",
|
||||
name="uq_pmflow_agent_verifications_tenant_run_public_verification",
|
||||
),
|
||||
)
|
||||
_create_index_once("ix_pmflow_agent_verifications_tenant_id", "pmflow_agent_verifications", ["tenant_id"])
|
||||
_create_index_once("ix_pmflow_agent_verifications_run_id", "pmflow_agent_verifications", ["run_id"])
|
||||
_create_index_once("ix_pmflow_agent_verifications_step_id", "pmflow_agent_verifications", ["step_id"])
|
||||
_create_index_once("ix_pmflow_agent_verifications_public_verification_id", "pmflow_agent_verifications", ["public_verification_id"])
|
||||
_create_index_once("ix_pmflow_agent_verifications_target_ref", "pmflow_agent_verifications", ["target_ref"])
|
||||
_create_index_once("ix_pmflow_agent_verifications_result", "pmflow_agent_verifications", ["result"])
|
||||
_create_index_once("ix_pmflow_agent_verifications_verified_at", "pmflow_agent_verifications", ["verified_at"])
|
||||
|
||||
if not _table_exists("pmflow_agent_memories"):
|
||||
op.create_table(
|
||||
"pmflow_agent_memories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"run_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("pmflow_agent_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("memory_kind", sa.String(length=80), nullable=False),
|
||||
sa.Column("scope_ref", sa.String(length=160), nullable=True),
|
||||
sa.Column("outcome_signal", sa.String(length=80), nullable=True),
|
||||
sa.Column("content", json_type, nullable=True),
|
||||
sa.Column("memory_payload", json_type, nullable=True),
|
||||
*_base_columns(),
|
||||
)
|
||||
_create_index_once("ix_pmflow_agent_memories_tenant_id", "pmflow_agent_memories", ["tenant_id"])
|
||||
_create_index_once("ix_pmflow_agent_memories_run_id", "pmflow_agent_memories", ["run_id"])
|
||||
_create_index_once("ix_pmflow_agent_memories_memory_kind", "pmflow_agent_memories", ["memory_kind"])
|
||||
_create_index_once("ix_pmflow_agent_memories_scope_ref", "pmflow_agent_memories", ["scope_ref"])
|
||||
_create_index_once("ix_pmflow_agent_memories_outcome_signal", "pmflow_agent_memories", ["outcome_signal"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_drop_table_once("pmflow_agent_memories")
|
||||
_drop_table_once("pmflow_agent_verifications")
|
||||
_drop_table_once("pmflow_agent_artifacts")
|
||||
_drop_table_once("pmflow_agent_observations")
|
||||
_drop_table_once("pmflow_agent_steps")
|
||||
_drop_table_once("pmflow_agent_runs")
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
"""add pmflow ai threads and messages
|
||||
|
||||
Revision ID: 20260513_add_pmflow_ai_threads
|
||||
Revises: 20260513_merge_pmflow_agent_runtime_heads
|
||||
Create Date: 2026-05-13
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260513_add_pmflow_ai_threads"
|
||||
down_revision = "20260513_merge_pmflow_agent_runtime_heads"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _json_type():
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
return postgresql.JSONB()
|
||||
return sa.JSON()
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return sa.inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
return any(item.get("name") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
return any(item.get("name") == index_name for item in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if _table_exists(table_name) and not _index_exists(table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def _base_columns() -> list[sa.Column]:
|
||||
return [
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.text("now()")),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
json_type = _json_type()
|
||||
|
||||
if not _table_exists("pmflow_ai_threads"):
|
||||
op.create_table(
|
||||
"pmflow_ai_threads",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("public_thread_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("qa_session_id", sa.BigInteger(), sa.ForeignKey("qa_sessions.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("workspace_scope", sa.String(length=80), nullable=True),
|
||||
sa.Column("surface", sa.String(length=80), nullable=True),
|
||||
sa.Column("title", sa.String(length=240), nullable=False, server_default="PMFlow AI"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("current_object_refs", json_type, nullable=True),
|
||||
sa.Column("last_run_public_id", sa.String(length=80), nullable=True),
|
||||
sa.Column("last_message_preview", sa.Text(), nullable=True),
|
||||
sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("thread_metadata", json_type, nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("last_message_at", sa.TIMESTAMP(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint("tenant_id", "public_thread_id", name="uq_pmflow_ai_threads_tenant_public_thread_id"),
|
||||
)
|
||||
_create_index_once("ix_pmflow_ai_threads_tenant_id", "pmflow_ai_threads", ["tenant_id"])
|
||||
_create_index_once("ix_pmflow_ai_threads_public_thread_id", "pmflow_ai_threads", ["public_thread_id"])
|
||||
_create_index_once("ix_pmflow_ai_threads_user_id", "pmflow_ai_threads", ["user_id"])
|
||||
_create_index_once("ix_pmflow_ai_threads_qa_session_id", "pmflow_ai_threads", ["qa_session_id"])
|
||||
_create_index_once("ix_pmflow_ai_threads_workspace_scope", "pmflow_ai_threads", ["workspace_scope"])
|
||||
_create_index_once("ix_pmflow_ai_threads_status", "pmflow_ai_threads", ["status"])
|
||||
_create_index_once("ix_pmflow_ai_threads_last_run_public_id", "pmflow_ai_threads", ["last_run_public_id"])
|
||||
_create_index_once("ix_pmflow_ai_threads_started_at", "pmflow_ai_threads", ["started_at"])
|
||||
_create_index_once("ix_pmflow_ai_threads_last_message_at", "pmflow_ai_threads", ["last_message_at"])
|
||||
|
||||
if _table_exists("pmflow_agent_runs") and not _column_exists("pmflow_agent_runs", "thread_id"):
|
||||
op.add_column("pmflow_agent_runs", sa.Column("thread_id", sa.BigInteger(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_pmflow_agent_runs_thread_id_pmflow_ai_threads",
|
||||
"pmflow_agent_runs",
|
||||
"pmflow_ai_threads",
|
||||
["thread_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
_create_index_once("ix_pmflow_agent_runs_thread_id", "pmflow_agent_runs", ["thread_id"])
|
||||
|
||||
if not _table_exists("pmflow_ai_messages"):
|
||||
op.create_table(
|
||||
"pmflow_ai_messages",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_ai_threads.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("public_message_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("role", sa.String(length=24), nullable=False),
|
||||
sa.Column("message_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("run_public_id", sa.String(length=80), nullable=True),
|
||||
sa.Column("qa_record_id", sa.BigInteger(), sa.ForeignKey("qa_records.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("message_payload", json_type, nullable=True),
|
||||
sa.Column("sent_at", sa.TIMESTAMP(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint("tenant_id", "thread_id", "public_message_id", name="uq_pmflow_ai_messages_tenant_thread_public_message"),
|
||||
)
|
||||
_create_index_once("ix_pmflow_ai_messages_tenant_id", "pmflow_ai_messages", ["tenant_id"])
|
||||
_create_index_once("ix_pmflow_ai_messages_thread_id", "pmflow_ai_messages", ["thread_id"])
|
||||
_create_index_once("ix_pmflow_ai_messages_public_message_id", "pmflow_ai_messages", ["public_message_id"])
|
||||
_create_index_once("ix_pmflow_ai_messages_role", "pmflow_ai_messages", ["role"])
|
||||
_create_index_once("ix_pmflow_ai_messages_message_index", "pmflow_ai_messages", ["message_index"])
|
||||
_create_index_once("ix_pmflow_ai_messages_run_public_id", "pmflow_ai_messages", ["run_public_id"])
|
||||
_create_index_once("ix_pmflow_ai_messages_qa_record_id", "pmflow_ai_messages", ["qa_record_id"])
|
||||
_create_index_once("ix_pmflow_ai_messages_sent_at", "pmflow_ai_messages", ["sent_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _table_exists("pmflow_ai_messages"):
|
||||
op.drop_table("pmflow_ai_messages")
|
||||
if _table_exists("pmflow_agent_runs") and _column_exists("pmflow_agent_runs", "thread_id"):
|
||||
op.drop_column("pmflow_agent_runs", "thread_id")
|
||||
if _table_exists("pmflow_ai_threads"):
|
||||
op.drop_table("pmflow_ai_threads")
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
"""merge pmflow agent runtime migration heads
|
||||
|
||||
Revision ID: 20260513_merge_pmflow_agent_runtime_heads
|
||||
Revises: 20260429_add_ai_call_log_provider_payloads, 20260513_add_pmflow_agent_runtime
|
||||
Create Date: 2026-05-13
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
revision = "20260513_merge_pmflow_agent_runtime_heads"
|
||||
down_revision = (
|
||||
"20260429_add_ai_call_log_provider_payloads",
|
||||
"20260513_add_pmflow_agent_runtime",
|
||||
)
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"""add agent run linkage to ai call logs
|
||||
|
||||
Revision ID: 20260514_add_agent_run_audit_linkage
|
||||
Revises: 20260513_add_pmflow_ai_threads
|
||||
Create Date: 2026-05-14
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260514_add_agent_run_audit_linkage"
|
||||
down_revision = "20260513_add_pmflow_ai_threads"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
return sa.inspect(op.get_bind()).has_table(table_name)
|
||||
|
||||
|
||||
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
return any(item.get("name") == column_name for item in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
return any(item.get("name") == index_name for item in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _create_index_once(index_name: str, table_name: str, columns: list[str]) -> None:
|
||||
if _table_exists(table_name) and not _index_exists(table_name, index_name):
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _table_exists("ai_call_logs"):
|
||||
return
|
||||
if not _column_exists("ai_call_logs", "agent_run_id"):
|
||||
op.add_column("ai_call_logs", sa.Column("agent_run_id", sa.String(length=80), nullable=True))
|
||||
if not _column_exists("ai_call_logs", "agent_step_key"):
|
||||
op.add_column("ai_call_logs", sa.Column("agent_step_key", sa.String(length=120), nullable=True))
|
||||
_create_index_once("ix_ai_call_logs_agent_run_id", "ai_call_logs", ["agent_run_id"])
|
||||
_create_index_once("ix_ai_call_logs_agent_step_key", "ai_call_logs", ["agent_step_key"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _table_exists("ai_call_logs"):
|
||||
return
|
||||
if _index_exists("ai_call_logs", "ix_ai_call_logs_agent_step_key"):
|
||||
op.drop_index("ix_ai_call_logs_agent_step_key", table_name="ai_call_logs")
|
||||
if _index_exists("ai_call_logs", "ix_ai_call_logs_agent_run_id"):
|
||||
op.drop_index("ix_ai_call_logs_agent_run_id", table_name="ai_call_logs")
|
||||
if _column_exists("ai_call_logs", "agent_step_key"):
|
||||
op.drop_column("ai_call_logs", "agent_step_key")
|
||||
if _column_exists("ai_call_logs", "agent_run_id"):
|
||||
op.drop_column("ai_call_logs", "agent_run_id")
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
"""seed PMFlow AI runtime prompt releases
|
||||
|
||||
Revision ID: 20260515_seed_pmflow_ai_runtime_prompt_releases
|
||||
Revises: 20260514_add_agent_run_audit_linkage
|
||||
Create Date: 2026-05-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260515_seed_pmflow_ai_runtime_prompt_releases"
|
||||
down_revision = "20260514_add_agent_run_audit_linkage"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"PROMPT_PMFLOW_AI_OPERATING_MISSION",
|
||||
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
|
||||
"PROMPT_PMFLOW_AI_AGENT_STEP_PLANNER",
|
||||
"PROMPT_PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER",
|
||||
"PROMPT_PMFLOW_AI_AGENT_STATE_CRITIC",
|
||||
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
|
||||
"PROMPT_PMFLOW_AI_AGENT_ANSWER_REVISER",
|
||||
)
|
||||
SEED_NOTES = "Seeded by 20260515_seed_pmflow_ai_runtime_prompt_releases"
|
||||
REFRESH_NOTES = "Refreshed by 20260515_seed_pmflow_ai_runtime_prompt_releases for operating-agent runtime"
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _prompt_body(prompt_key: str) -> str:
|
||||
from app.config import ai_prompts
|
||||
|
||||
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
|
||||
|
||||
|
||||
def _seed_active_release(bind, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
|
||||
active = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, body_snapshot FROM prompt_releases
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND prompt_key = :prompt_key
|
||||
AND status = 'active'
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "prompt_key": prompt_key},
|
||||
).mappings().first()
|
||||
if active is not None and str(active.get("body_snapshot") or "").strip() == body_snapshot:
|
||||
return
|
||||
if active is not None:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE prompt_releases
|
||||
SET status = 'archived'
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND prompt_key = :prompt_key
|
||||
AND status = 'active'
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "prompt_key": prompt_key},
|
||||
)
|
||||
|
||||
max_version = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT MAX(version) FROM prompt_releases
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND prompt_key = :prompt_key
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "prompt_key": prompt_key},
|
||||
).scalar()
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO prompt_releases
|
||||
(tenant_id, prompt_key, version, body_snapshot, status, notes, created_by, activated_by, activated_at)
|
||||
VALUES
|
||||
(:tenant_id, :prompt_key, :version, :body_snapshot, 'active', :notes, NULL, NULL, :activated_at)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"prompt_key": prompt_key,
|
||||
"version": int(max_version or 0) + 1,
|
||||
"body_snapshot": body_snapshot,
|
||||
"notes": REFRESH_NOTES if active is not None else SEED_NOTES,
|
||||
"activated_at": "2026-05-15T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_releases"):
|
||||
return
|
||||
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
for prompt_key in PROMPT_KEYS:
|
||||
body_snapshot = _prompt_body(prompt_key)
|
||||
if body_snapshot:
|
||||
_seed_active_release(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_releases"):
|
||||
return
|
||||
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM prompt_releases
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND prompt_key IN :prompt_keys
|
||||
AND notes IN :notes
|
||||
"""
|
||||
).bindparams(sa.bindparam("prompt_keys", expanding=True), sa.bindparam("notes", expanding=True)),
|
||||
{"tenant_id": tenant_id, "prompt_keys": PROMPT_KEYS, "notes": (SEED_NOTES, REFRESH_NOTES)},
|
||||
)
|
||||
|
|
@ -1,55 +1,11 @@
|
|||
"""
|
||||
API 路由模块
|
||||
"""API package.
|
||||
|
||||
按业务域拆分路由,便于维护与扩展。
|
||||
Route registration lives in ``app.api.router``. Keep this package entrypoint
|
||||
lightweight so focused tests can import a single API module without importing
|
||||
optional integrations such as Feishu or MCP.
|
||||
"""
|
||||
|
||||
from .signals_api import router as signals_router
|
||||
from .feedbacks import router as feedbacks_router
|
||||
from .experiments import router as experiments_router
|
||||
from .context import router as context_router
|
||||
from .tasks import router as tasks_router
|
||||
from .webhooks import router as webhooks_router
|
||||
from .auth import router as auth_router
|
||||
from .users_api import router as users_router
|
||||
from .endpoints.skill import router as skill_router
|
||||
from .sstc_api import router as sstc_router
|
||||
from .ai_steward_api import router as ai_steward_router
|
||||
from .feishu_api import router as feishu_router
|
||||
from .wecom_api import router as wecom_router
|
||||
from .model_capability_api import router as model_capability_router
|
||||
from .budget import router as budget_router
|
||||
from .scheduler import router as scheduler_router
|
||||
from .notifications import router as notifications_router
|
||||
from .llm_engines import router as llm_engines_router
|
||||
from .llm_providers import router as llm_providers_router
|
||||
from .prompt_profiles import router as prompt_profiles_router
|
||||
from .knowledge_graph import router as knowledge_graph_router
|
||||
from .operation_catalog import router as operation_catalog_router
|
||||
from .ai_pmo_api import router as ai_pmo_router
|
||||
|
||||
__all__ = [
|
||||
"signals_router",
|
||||
"feedbacks_router",
|
||||
"experiments_router",
|
||||
"context_router",
|
||||
"tasks_router",
|
||||
"webhooks_router",
|
||||
"auth_router",
|
||||
"users_router",
|
||||
"skill_router",
|
||||
"sstc_router",
|
||||
"ai_steward_router",
|
||||
"feishu_router",
|
||||
"wecom_router",
|
||||
"model_capability_router",
|
||||
"budget_router",
|
||||
"scheduler_router",
|
||||
"notifications_router",
|
||||
"llm_engines_router",
|
||||
"llm_providers_router",
|
||||
"prompt_profiles_router",
|
||||
"knowledge_graph_router",
|
||||
"operation_catalog_router",
|
||||
"ai_pmo_router",
|
||||
"pmflow_launch_api",
|
||||
"pmflow_os_api",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.response import error_response, success_response
|
||||
from ...dependencies import get_current_user
|
||||
from ...models import User
|
||||
from ...schemas.account_settings import AccountSettingsUpdateRequest
|
||||
from ...services.account_settings_service import AccountSettingsService
|
||||
|
||||
router = APIRouter(prefix="/account-settings", tags=["account-settings"])
|
||||
|
||||
|
||||
def _service(db: AsyncSession, current_user: User) -> AccountSettingsService:
|
||||
return AccountSettingsService(db, tenant_id=int(current_user.tenant_id))
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_my_account_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
settings = await _service(db, current_user).get_settings(user=current_user)
|
||||
return success_response(settings.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.put("/me")
|
||||
async def update_my_account_settings(
|
||||
body: AccountSettingsUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
service = _service(db, current_user)
|
||||
try:
|
||||
settings = await service.update_settings(user=current_user, body=body)
|
||||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
await db.commit()
|
||||
return success_response(settings.model_dump(mode="json"))
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"""Platform full-chain Agent OS audit API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.response import error_response, success_response
|
||||
from ..dependencies.permissions import require_platform_capability
|
||||
from ..models import User
|
||||
from ..services.agent_run_audit_service import AgentRunAuditService
|
||||
|
||||
router = APIRouter(prefix="/agent-run-audits", tags=["agent_run_audits"])
|
||||
|
||||
|
||||
require_platform_audit_view = require_platform_capability("platform.audit.view")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_agent_run_audits(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=30, ge=1, le=100),
|
||||
tenant_id: Optional[int] = Query(default=None),
|
||||
user_id: Optional[int] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None, max_length=80),
|
||||
source_channel: Optional[str] = Query(default=None, max_length=80),
|
||||
keyword: Optional[str] = Query(default=None, max_length=300),
|
||||
since_hours: Optional[int] = Query(default=168, ge=1, le=8760),
|
||||
current_user: User = Depends(require_platform_audit_view),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
data = await AgentRunAuditService(db).list_runs(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
source_channel=source_channel,
|
||||
keyword=keyword,
|
||||
since_hours=since_hours,
|
||||
)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/{public_run_id}")
|
||||
async def get_agent_run_audit(
|
||||
public_run_id: str,
|
||||
current_user: User = Depends(require_platform_audit_view),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
try:
|
||||
data = await AgentRunAuditService(db).get_run(public_run_id)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
|
||||
return success_response(data)
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"""AI call trace audit API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.response import error_response, success_response
|
||||
from ..dependencies.permissions import require_platform_ai_ops
|
||||
from ..models import User
|
||||
from ..services.ai_call_trace_service import AICallTraceService
|
||||
|
||||
router = APIRouter(prefix="/ai-call-traces", tags=["ai_call_traces"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_ai_call_traces(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=30, ge=1, le=100),
|
||||
status: Optional[str] = Query(default=None, max_length=80),
|
||||
provider: Optional[str] = Query(default=None, max_length=80),
|
||||
model: Optional[str] = Query(default=None, max_length=160),
|
||||
function_name: Optional[str] = Query(default=None, max_length=255),
|
||||
scene_key: Optional[str] = Query(default=None, max_length=120),
|
||||
slot_key: Optional[str] = Query(default=None, max_length=80),
|
||||
prompt_key: Optional[str] = Query(default=None, max_length=128),
|
||||
prompt_source: Optional[str] = Query(default=None, max_length=32),
|
||||
prompt_release_id: Optional[int] = Query(default=None),
|
||||
trace_id: Optional[str] = Query(default=None, max_length=120),
|
||||
tenant_id: Optional[int] = Query(default=None),
|
||||
keyword: Optional[str] = Query(default=None, max_length=300),
|
||||
since_hours: Optional[int] = Query(default=168, ge=1, le=8760),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
data = await AICallTraceService(db).list_traces(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status=status,
|
||||
provider=provider,
|
||||
model=model,
|
||||
function_name=function_name,
|
||||
scene_key=scene_key,
|
||||
slot_key=slot_key,
|
||||
prompt_key=prompt_key,
|
||||
prompt_source=prompt_source,
|
||||
prompt_release_id=prompt_release_id,
|
||||
trace_id=trace_id,
|
||||
tenant_id=tenant_id,
|
||||
keyword=keyword,
|
||||
since_hours=since_hours,
|
||||
)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/{trace_id_or_log_id}")
|
||||
async def get_ai_call_trace(
|
||||
trace_id_or_log_id: str,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
try:
|
||||
data = await AICallTraceService(db).get_trace(trace_id_or_log_id)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
|
||||
return success_response(data)
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
"""AI 处理可视化日志 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import and_, case, func, or_, select, union_all
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.response import success_response
|
||||
from ..database import get_db
|
||||
from ..dependencies.auth import get_current_user
|
||||
from ..models import (
|
||||
AIProcessingLog,
|
||||
FeishuItem,
|
||||
ProjectSignalLink,
|
||||
ProjectSuggestion,
|
||||
Signal,
|
||||
User,
|
||||
WorkItem,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/ai-logs", tags=["AI Logs"])
|
||||
|
||||
|
||||
def _normalize_bullets(value: Any) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return [str(x).strip() for x in value if str(x).strip()][:3]
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, list):
|
||||
return [str(x).strip() for x in parsed if str(x).strip()][:3]
|
||||
except Exception:
|
||||
pass
|
||||
return [line.strip(" -•\t") for line in raw.splitlines() if line.strip()][:3]
|
||||
return []
|
||||
|
||||
|
||||
def _safe_json(value: Optional[str]) -> Any:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_log_row(row: AIProcessingLog) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"feishu_message_id": row.feishu_message_id,
|
||||
"feishu_item_id": str(row.feishu_item_id) if row.feishu_item_id is not None else None,
|
||||
"raw_message": row.raw_message,
|
||||
"project_score": row.project_score,
|
||||
"signal_confidence": row.signal_confidence,
|
||||
"level": row.level,
|
||||
"routing_decision": row.routing_decision,
|
||||
"routing_reason": row.routing_reason,
|
||||
"reasoning_bullets": _normalize_bullets(row.reasoning_bullets),
|
||||
"processing_status": row.processing_status,
|
||||
"error_message": row.error_message,
|
||||
"duration_ms": row.duration_ms,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_log_detail(row: AIProcessingLog) -> dict[str, Any]:
|
||||
payload = _serialize_log_row(row)
|
||||
payload["ai_raw_output"] = row.ai_raw_output
|
||||
payload["ai_raw_output_json"] = _safe_json(row.ai_raw_output)
|
||||
return payload
|
||||
|
||||
|
||||
def _apply_project_filter(stmt, *, tenant_id: int, project_id: Optional[int]):
|
||||
if project_id is None:
|
||||
return stmt
|
||||
|
||||
work_item_message_ids = select(func.distinct(WorkItem.source_message_id)).where(
|
||||
and_(
|
||||
WorkItem.tenant_id == tenant_id,
|
||||
WorkItem.project_id == int(project_id),
|
||||
WorkItem.source_system == "feishu",
|
||||
WorkItem.source_message_id.is_not(None),
|
||||
)
|
||||
)
|
||||
direct_items = select(FeishuItem.id.label("item_id")).where(
|
||||
and_(
|
||||
FeishuItem.tenant_id == tenant_id,
|
||||
FeishuItem.matched_project_id == int(project_id),
|
||||
)
|
||||
)
|
||||
signal_matched_items = select(Signal.feishu_item_id.label("item_id")).where(
|
||||
and_(
|
||||
Signal.tenant_id == tenant_id,
|
||||
Signal.matched_project_id == int(project_id),
|
||||
Signal.feishu_item_id.is_not(None),
|
||||
)
|
||||
)
|
||||
linked_items = (
|
||||
select(Signal.feishu_item_id.label("item_id"))
|
||||
.select_from(Signal)
|
||||
.join(
|
||||
ProjectSignalLink,
|
||||
and_(
|
||||
ProjectSignalLink.tenant_id == tenant_id,
|
||||
ProjectSignalLink.project_id == int(project_id),
|
||||
ProjectSignalLink.signal_id == Signal.id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Signal.tenant_id == tenant_id,
|
||||
Signal.feishu_item_id.is_not(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
auto_created_items = select(ProjectSuggestion.feishu_item_id.label("item_id")).where(
|
||||
and_(
|
||||
ProjectSuggestion.tenant_id == tenant_id,
|
||||
ProjectSuggestion.created_project_id == int(project_id),
|
||||
ProjectSuggestion.feishu_item_id.is_not(None),
|
||||
)
|
||||
)
|
||||
|
||||
all_item_ids = union_all(
|
||||
direct_items,
|
||||
signal_matched_items,
|
||||
linked_items,
|
||||
auto_created_items,
|
||||
).subquery("project_item_ids")
|
||||
item_ids_stmt = select(func.distinct(all_item_ids.c.item_id)).where(all_item_ids.c.item_id.is_not(None))
|
||||
message_ids_stmt = select(func.distinct(FeishuItem.feishu_message_id)).where(
|
||||
and_(
|
||||
FeishuItem.tenant_id == tenant_id,
|
||||
FeishuItem.id.in_(item_ids_stmt),
|
||||
FeishuItem.feishu_message_id.is_not(None),
|
||||
)
|
||||
)
|
||||
return stmt.where(
|
||||
or_(
|
||||
AIProcessingLog.feishu_item_id.in_(item_ids_stmt),
|
||||
AIProcessingLog.feishu_message_id.in_(message_ids_stmt),
|
||||
AIProcessingLog.feishu_message_id.in_(work_item_message_ids),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_ai_logs(
|
||||
project_id: Optional[int] = Query(default=None, ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
total_stmt = select(func.count(AIProcessingLog.id)).where(AIProcessingLog.tenant_id == tenant_id)
|
||||
data_stmt = (
|
||||
select(AIProcessingLog)
|
||||
.where(AIProcessingLog.tenant_id == tenant_id)
|
||||
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
total_stmt = _apply_project_filter(total_stmt, tenant_id=tenant_id, project_id=project_id)
|
||||
data_stmt = _apply_project_filter(data_stmt, tenant_id=tenant_id, project_id=project_id)
|
||||
|
||||
total = int((await db.execute(total_stmt)).scalar() or 0)
|
||||
rows = (await db.execute(data_stmt)).scalars().all()
|
||||
items = [_serialize_log_row(row) for row in rows]
|
||||
return success_response(
|
||||
{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + len(items)) < total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_ai_log_stats(
|
||||
project_id: Optional[int] = Query(default=None, ge=1),
|
||||
date_value: str = Query(default="today", alias="date"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
if date_value == "today":
|
||||
target_date = datetime.now().date()
|
||||
else:
|
||||
try:
|
||||
target_date = date.fromisoformat(date_value)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"invalid_date:{date_value}") from exc
|
||||
|
||||
stats_stmt = (
|
||||
select(
|
||||
func.count(AIProcessingLog.id).label("received_count"),
|
||||
func.sum(case((AIProcessingLog.processing_status == "success", 1), else_=0)).label("success_count"),
|
||||
func.sum(case((AIProcessingLog.processing_status == "failed", 1), else_=0)).label("failed_count"),
|
||||
func.sum(
|
||||
case((AIProcessingLog.routing_decision == "duplicate_filtered", 1), else_=0)
|
||||
).label("duplicate_filtered_count"),
|
||||
func.sum(
|
||||
case((AIProcessingLog.routing_decision == "execute_reply_guard_triggered", 1), else_=0)
|
||||
).label("execute_reply_guard_count"),
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
AIProcessingLog.routing_decision.in_(
|
||||
["auto_create_project", "auto_enter_signal"]
|
||||
),
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
).label("auto_entered_count"),
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
AIProcessingLog.routing_decision.in_(
|
||||
[
|
||||
"pending_low_confidence",
|
||||
"pending_cold_start",
|
||||
"pending_uncertain_project",
|
||||
]
|
||||
),
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
).label("pending_count"),
|
||||
func.sum(case((AIProcessingLog.processing_status == "processing", 1), else_=0)).label(
|
||||
"processing_count"
|
||||
),
|
||||
func.avg(AIProcessingLog.signal_confidence).label("avg_signal_confidence"),
|
||||
func.avg(AIProcessingLog.project_score).label("avg_project_score"),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
AIProcessingLog.tenant_id == tenant_id,
|
||||
func.date(AIProcessingLog.created_at) == target_date.isoformat(),
|
||||
)
|
||||
)
|
||||
)
|
||||
stats_stmt = _apply_project_filter(stats_stmt, tenant_id=tenant_id, project_id=project_id)
|
||||
row = (await db.execute(stats_stmt)).one()
|
||||
return success_response(
|
||||
{
|
||||
"date": target_date.isoformat(),
|
||||
"received_count": int(row.received_count or 0),
|
||||
"success_count": int(row.success_count or 0),
|
||||
"failed_count": int(row.failed_count or 0),
|
||||
"duplicate_filtered_count": int(row.duplicate_filtered_count or 0),
|
||||
"execute_reply_guard_count": int(row.execute_reply_guard_count or 0),
|
||||
"auto_entered_count": int(row.auto_entered_count or 0),
|
||||
"pending_count": int(row.pending_count or 0),
|
||||
"processing_count": int(row.processing_count or 0),
|
||||
"avg_signal_confidence": round(float(row.avg_signal_confidence or 0.0), 4),
|
||||
"avg_project_score": round(float(row.avg_project_score or 0.0), 4),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/alerts/execute-reply-guard")
|
||||
async def list_execute_reply_guard_alerts(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(AIProcessingLog)
|
||||
.where(
|
||||
and_(
|
||||
AIProcessingLog.tenant_id == tenant_id,
|
||||
AIProcessingLog.routing_decision == "execute_reply_guard_triggered",
|
||||
)
|
||||
)
|
||||
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
return success_response(
|
||||
{
|
||||
"count": len(rows),
|
||||
"items": [_serialize_log_row(row) for row in rows],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/latest")
|
||||
async def get_latest_ai_log(
|
||||
project_id: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
stmt = (
|
||||
select(AIProcessingLog)
|
||||
.where(AIProcessingLog.tenant_id == tenant_id)
|
||||
.order_by(AIProcessingLog.created_at.desc(), AIProcessingLog.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
stmt = _apply_project_filter(stmt, tenant_id=tenant_id, project_id=project_id)
|
||||
row = (await db.execute(stmt)).scalar_one_or_none()
|
||||
return success_response(_serialize_log_detail(row) if row else None)
|
||||
|
||||
|
||||
@router.get("/{log_id}")
|
||||
async def get_ai_log_detail(
|
||||
log_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
row = (
|
||||
await db.execute(
|
||||
select(AIProcessingLog).where(
|
||||
and_(
|
||||
AIProcessingLog.tenant_id == tenant_id,
|
||||
AIProcessingLog.id == int(log_id),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="ai_log_not_found")
|
||||
return success_response(_serialize_log_detail(row))
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
"""AI PMO 决策中枢 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.response import success_response
|
||||
from ..database import get_db
|
||||
from ..dependencies.auth import get_current_user
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
|
||||
from ..services.ai_pmo_cadence_service import AIPMOCadenceService
|
||||
from ..services.ai_pmo_kernel_service import AIPMOKernelService
|
||||
|
||||
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
|
||||
|
||||
|
||||
class PMOProcessTextRequest(BaseModel):
|
||||
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||
sender: Optional[str] = Field(default=None, max_length=120)
|
||||
source_message_id: Optional[str] = Field(default=None, max_length=128)
|
||||
project_id: Optional[int] = Field(default=None)
|
||||
|
||||
|
||||
class PMOCadenceRunRequest(BaseModel):
|
||||
cadence_type: str = Field(..., min_length=1, max_length=32)
|
||||
project_id: Optional[int] = Field(default=None)
|
||||
|
||||
|
||||
class PMOResumeWorkItemRequest(BaseModel):
|
||||
project_id: Optional[int] = Field(default=None)
|
||||
source_message_id: Optional[str] = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
@router.post("/process-text")
|
||||
async def process_text(
|
||||
request: PMOProcessTextRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AIPMOKernelService(db, tenant_id=tenant_id)
|
||||
result = await service.process_ingress(
|
||||
source_system="pmflow_manual",
|
||||
source_message_id=request.source_message_id,
|
||||
message_text=request.message_text,
|
||||
sender=str(request.sender or current_user.name or current_user.username or "PMFlow用户"),
|
||||
channel_ref="pmflow_manual",
|
||||
source_sender_open_id=None,
|
||||
captured_at=None,
|
||||
chat_type="manual",
|
||||
project_hint_id=request.project_id,
|
||||
)
|
||||
if not result.get("handled"):
|
||||
raise HTTPException(status_code=400, detail=str(result.get("reason") or "AI PMO kernel 未处理该输入"))
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.get("/work-items")
|
||||
async def list_work_items(
|
||||
limit: int = 20,
|
||||
state: Optional[str] = None,
|
||||
project_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
stmt = (
|
||||
select(WorkItem, IngestEvent, Project)
|
||||
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
|
||||
.outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id))
|
||||
.where(WorkItem.tenant_id == tenant_id)
|
||||
.order_by(WorkItem.updated_at.desc(), WorkItem.id.desc())
|
||||
)
|
||||
if state:
|
||||
stmt = stmt.where(WorkItem.state == state)
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(WorkItem.project_id == int(project_id))
|
||||
rows = (await db.execute(stmt.limit(max(1, min(limit, 100))))).all()
|
||||
items = [
|
||||
_serialize_work_item_summary(row=row, ingest=ingest, project=project)
|
||||
for row, ingest, project in rows
|
||||
]
|
||||
return success_response({"items": items})
|
||||
|
||||
|
||||
@router.get("/work-items/{work_item_id}")
|
||||
async def get_work_item(
|
||||
work_item_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
row = (
|
||||
await db.execute(
|
||||
select(WorkItem).where(
|
||||
and_(
|
||||
WorkItem.tenant_id == tenant_id,
|
||||
WorkItem.id == int(work_item_id),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="work_item_not_found")
|
||||
decision_rows = (
|
||||
await db.execute(
|
||||
select(DecisionRecord).where(
|
||||
and_(
|
||||
DecisionRecord.tenant_id == tenant_id,
|
||||
DecisionRecord.work_item_id == int(work_item_id),
|
||||
)
|
||||
).order_by(DecisionRecord.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
plan_rows = (
|
||||
await db.execute(
|
||||
select(PlanBundle).where(
|
||||
and_(
|
||||
PlanBundle.tenant_id == tenant_id,
|
||||
PlanBundle.work_item_id == int(work_item_id),
|
||||
)
|
||||
).order_by(PlanBundle.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
reply_rows = (
|
||||
await db.execute(
|
||||
select(ReplyReceipt).where(
|
||||
and_(
|
||||
ReplyReceipt.tenant_id == tenant_id,
|
||||
ReplyReceipt.work_item_id == int(work_item_id),
|
||||
)
|
||||
).order_by(ReplyReceipt.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
payload: dict[str, Any] = {
|
||||
"id": int(row.id),
|
||||
"source_system": row.source_system,
|
||||
"source_message_id": row.source_message_id,
|
||||
"state": row.state,
|
||||
"intent": row.intent,
|
||||
"decision_level": row.decision_level,
|
||||
"project_id": _safe_int(row.project_id),
|
||||
"signal_id": _safe_int(row.signal_id),
|
||||
"context_snapshot": row.context_snapshot,
|
||||
"action_bundle": row.action_bundle,
|
||||
"execution_result": row.execution_result,
|
||||
"review_summary": row.review_summary,
|
||||
"reply_template_key": row.reply_template_key,
|
||||
"decision_records": [
|
||||
{
|
||||
"id": int(item.id),
|
||||
"decision_level": item.decision_level,
|
||||
"decision_object_type": item.decision_object_type,
|
||||
"decision_object_id": _safe_int(item.decision_object_id),
|
||||
"rationale": item.rationale,
|
||||
"risk_score": item.risk_score,
|
||||
"budget_impact": item.budget_impact,
|
||||
"resource_impact": item.resource_impact,
|
||||
"simulation_result": item.simulation_result,
|
||||
"policy_gate_hit": bool(item.policy_gate_hit),
|
||||
"policy_gate_code": item.policy_gate_code,
|
||||
"approval_requirement": item.approval_requirement,
|
||||
"execution_result": item.execution_result,
|
||||
"final_outcome": item.final_outcome,
|
||||
}
|
||||
for item in decision_rows
|
||||
],
|
||||
"plan_bundles": [
|
||||
{
|
||||
"id": int(item.id),
|
||||
"plan_type": item.plan_type,
|
||||
"summary": item.summary,
|
||||
"payload": item.payload,
|
||||
}
|
||||
for item in plan_rows
|
||||
],
|
||||
"reply_receipts": [
|
||||
{
|
||||
"id": int(item.id),
|
||||
"receipt_phase": item.receipt_phase,
|
||||
"template_key": item.template_key,
|
||||
"payload": item.payload,
|
||||
"status": item.status,
|
||||
}
|
||||
for item in reply_rows
|
||||
],
|
||||
}
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.post("/work-items/{work_item_id}/resume")
|
||||
async def resume_work_item(
|
||||
work_item_id: int,
|
||||
request: PMOResumeWorkItemRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
target = (
|
||||
await db.execute(
|
||||
select(WorkItem).where(
|
||||
and_(
|
||||
WorkItem.tenant_id == tenant_id,
|
||||
WorkItem.id == int(work_item_id),
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="work_item_not_found")
|
||||
service = AIPMOKernelService(db, tenant_id=tenant_id)
|
||||
result = await service.process_ingress(
|
||||
source_system="ai_pmo_ui",
|
||||
source_message_id=request.source_message_id or f"resume-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}",
|
||||
message_text=f"继续处理 work_item #{int(work_item_id)}",
|
||||
sender=str(current_user.name or current_user.username or "PMFlow用户"),
|
||||
channel_ref="ai_pmo_ui",
|
||||
source_sender_open_id=None,
|
||||
captured_at=None,
|
||||
chat_type="manual",
|
||||
project_hint_id=request.project_id or _safe_int(target.project_id),
|
||||
)
|
||||
if not result.get("handled"):
|
||||
raise HTTPException(status_code=400, detail=str(result.get("reason") or "resume_failed"))
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.post("/cadence/run")
|
||||
async def run_cadence(
|
||||
request: PMOCadenceRunRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
service = AIPMOCadenceService(db, tenant_id=tenant_id)
|
||||
try:
|
||||
snapshot = await service.generate_snapshot(
|
||||
cadence_type=request.cadence_type,
|
||||
project_id=request.project_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
return success_response(snapshot)
|
||||
|
||||
|
||||
@router.get("/cadence/latest")
|
||||
async def get_latest_cadence_snapshot(
|
||||
cadence_type: str,
|
||||
project_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
service = AIPMOCadenceService(db, tenant_id=tenant_id)
|
||||
snapshot = await service.get_latest_snapshot(
|
||||
cadence_type=cadence_type,
|
||||
project_id=project_id,
|
||||
)
|
||||
return success_response(snapshot)
|
||||
|
||||
|
||||
@router.get("/portfolio/program-view")
|
||||
async def get_program_view(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
service = AIPMOCadenceService(db, tenant_id=tenant_id)
|
||||
return success_response(await service.build_program_view())
|
||||
|
||||
|
||||
@router.get("/portfolio/latest")
|
||||
async def get_latest_portfolio_snapshot(
|
||||
scope_type: str = "tenant",
|
||||
project_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
scope_ref = f"project:{int(project_id)}" if project_id is not None else f"tenant:{tenant_id}"
|
||||
row = (
|
||||
await db.execute(
|
||||
select(PortfolioSnapshot).where(
|
||||
and_(
|
||||
PortfolioSnapshot.tenant_id == tenant_id,
|
||||
PortfolioSnapshot.scope_type == scope_type,
|
||||
PortfolioSnapshot.scope_ref == scope_ref,
|
||||
)
|
||||
).order_by(PortfolioSnapshot.id.desc()).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return success_response(None)
|
||||
return success_response(
|
||||
{
|
||||
"id": int(row.id),
|
||||
"scope_type": row.scope_type,
|
||||
"scope_ref": row.scope_ref,
|
||||
"summary": row.summary,
|
||||
"payload": row.payload,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _compact_text(value: Any, limit: int) -> str:
|
||||
text = " ".join(str(value or "").split()).strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return f"{text[: max(limit - 1, 1)].rstrip()}…"
|
||||
|
||||
|
||||
def _priority_from_review(review_summary: Any) -> tuple[str, int]:
|
||||
payload = review_summary if isinstance(review_summary, dict) else {}
|
||||
risk_score = float(payload.get("risk_score_after") or 0.0)
|
||||
if risk_score >= 0.75:
|
||||
return "high", 3
|
||||
if risk_score >= 0.45:
|
||||
return "medium", 2
|
||||
return "low", 1
|
||||
|
||||
|
||||
def _blocking_reason(row: WorkItem) -> Optional[str]:
|
||||
execution_result = row.execution_result if isinstance(row.execution_result, dict) else {}
|
||||
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
|
||||
reason = str(execution_result.get("reason") or execution_result.get("error_message") or "").strip()
|
||||
if reason:
|
||||
return reason
|
||||
divergences = review_summary.get("divergences") if isinstance(review_summary.get("divergences"), list) else []
|
||||
if divergences and isinstance(divergences[0], dict):
|
||||
return str(divergences[0].get("detail") or "").strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _next_step_text(row: WorkItem) -> Optional[str]:
|
||||
review_summary = row.review_summary if isinstance(row.review_summary, dict) else {}
|
||||
next_actions = review_summary.get("next_actions") if isinstance(review_summary.get("next_actions"), list) else []
|
||||
if next_actions:
|
||||
return str(next_actions[0]).strip()
|
||||
action_bundle = row.action_bundle if isinstance(row.action_bundle, dict) else {}
|
||||
summary = str(action_bundle.get("summary") or "").strip()
|
||||
return summary or None
|
||||
|
||||
|
||||
def _stage_text(state: str) -> str:
|
||||
mapping = {
|
||||
"received": "已接收",
|
||||
"modeled": "已建模",
|
||||
"decided": "已决策",
|
||||
"planned": "已规划",
|
||||
"executing": "执行中",
|
||||
"waiting_manual": "待人工确认",
|
||||
"succeeded": "执行完成",
|
||||
"failed": "执行失败",
|
||||
"ignored": "已忽略",
|
||||
}
|
||||
return mapping.get(str(state or "").strip(), str(state or "").strip() or "-")
|
||||
|
||||
|
||||
def _serialize_work_item_summary(*, row: WorkItem, ingest: IngestEvent, project: Optional[Project]) -> dict[str, Any]:
|
||||
payload = ingest.payload if isinstance(ingest.payload, dict) else {}
|
||||
message_text = str(payload.get("message_text") or "").strip()
|
||||
summary = _compact_text(message_text or _next_step_text(row) or row.intent or "AI PMO 工作项", 180)
|
||||
title = _compact_text(message_text or f"AI PMO · {row.intent or 'work_item'}", 64)
|
||||
priority, priority_rank = _priority_from_review(row.review_summary)
|
||||
project_name = None
|
||||
if project is not None:
|
||||
project_name = str(project.title or project.name or "").strip() or None
|
||||
project_id = _safe_int(row.project_id)
|
||||
return {
|
||||
"id": int(row.id),
|
||||
"source_system": row.source_system,
|
||||
"source_message_id": row.source_message_id,
|
||||
"state": row.state,
|
||||
"intent": row.intent,
|
||||
"decision_level": row.decision_level,
|
||||
"project_id": project_id,
|
||||
"project_name": project_name,
|
||||
"signal_id": _safe_int(row.signal_id),
|
||||
"reply_template_key": row.reply_template_key,
|
||||
"title": title or f"工作项#{int(row.id)}",
|
||||
"summary": summary,
|
||||
"ai_plan": _compact_text(((row.action_bundle or {}) if isinstance(row.action_bundle, dict) else {}).get("summary") or "", 180) or None,
|
||||
"blocking_reason": _blocking_reason(row),
|
||||
"next_step_text": _next_step_text(row),
|
||||
"stage_text": _stage_text(str(row.state or "")),
|
||||
"priority": priority,
|
||||
"priority_rank": priority_rank,
|
||||
"target_url": f"/projects/{project_id}/board" if project_id is not None else None,
|
||||
"review_summary": row.review_summary,
|
||||
"execution_result": row.execution_result,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from .shared import _visible_work_item_filter
|
||||
from .helpers_inbox_filters import _build_latest_decision_projection, _should_hide_work_item_from_inbox, _safe_int
|
||||
from .helpers_inbox_payload import (
|
||||
_build_closure_payload_overrides,
|
||||
|
|
@ -11,6 +12,7 @@ from .helpers_inbox_payload import (
|
|||
from .schemas import PMOProcessTextRequest
|
||||
from ...services.ai_pmo.project_operating_context_builder import ProjectOperatingContextBuilder
|
||||
|
||||
|
||||
@router.post("/process-text")
|
||||
async def process_text(
|
||||
request: PMOProcessTextRequest,
|
||||
|
|
@ -52,12 +54,12 @@ async def list_work_items(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
stmt = (
|
||||
select(WorkItem, IngestEvent, Project)
|
||||
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
|
||||
.outerjoin(Project, and_(Project.tenant_id == tenant_id, Project.id == WorkItem.project_id))
|
||||
.where(WorkItem.tenant_id == tenant_id)
|
||||
.where(_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id))
|
||||
.order_by(WorkItem.updated_at.desc(), WorkItem.id.desc())
|
||||
)
|
||||
if state:
|
||||
|
|
@ -103,6 +105,7 @@ async def get_work_item(
|
|||
and_(
|
||||
WorkItem.tenant_id == tenant_id,
|
||||
WorkItem.id == int(work_item_id),
|
||||
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from .shared import _visible_work_item_filter
|
||||
from .helpers_resume import _queue_resume_work_item, _reject_work_item
|
||||
from .schemas import PMODraftDecisionRequest, PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest
|
||||
from ...services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
||||
|
|
@ -19,6 +20,7 @@ async def resume_work_item(
|
|||
and_(
|
||||
WorkItem.tenant_id == tenant_id,
|
||||
WorkItem.id == int(work_item_id),
|
||||
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
|
|
@ -61,6 +63,7 @@ async def decide_work_item(
|
|||
and_(
|
||||
WorkItem.tenant_id == tenant_id,
|
||||
WorkItem.id == int(work_item_id),
|
||||
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
|
|
@ -165,6 +168,7 @@ async def decide_draft_work_item(
|
|||
and_(
|
||||
WorkItem.tenant_id == tenant_id,
|
||||
WorkItem.id == int(work_item_id),
|
||||
_visible_work_item_filter(current_user=current_user, tenant_id=tenant_id),
|
||||
)
|
||||
).limit(1)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from typing import Any, Literal, Optional
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.logging import get_logger
|
||||
|
|
@ -14,10 +14,40 @@ from ...core.response import success_response
|
|||
from ...core.database import AsyncSessionLocal, get_db
|
||||
from ...dependencies.auth import get_current_user
|
||||
from ...dependencies.tenant import get_tenant_id
|
||||
from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
|
||||
from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, UserProject, WorkItem
|
||||
from ...services.auth import has_any_role
|
||||
from ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService
|
||||
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
|
||||
|
||||
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
|
||||
logger = get_logger(__name__)
|
||||
FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"}
|
||||
|
||||
|
||||
def _can_view_all_work_items(user: User) -> bool:
|
||||
return has_any_role(str(getattr(user, "role", "") or ""), ["OWNER", "REVIEWER"])
|
||||
|
||||
|
||||
def _visible_work_item_filter(*, current_user: User, tenant_id: int):
|
||||
if _can_view_all_work_items(current_user):
|
||||
return True
|
||||
return or_(
|
||||
WorkItem.owner_user_id == int(current_user.id),
|
||||
WorkItem.project_id.in_(
|
||||
select(Project.id)
|
||||
.outerjoin(
|
||||
UserProject,
|
||||
and_(
|
||||
UserProject.tenant_id == Project.tenant_id,
|
||||
UserProject.project_id == Project.id,
|
||||
UserProject.user_id == int(current_user.id),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Project.tenant_id == int(tenant_id),
|
||||
or_(Project.owner_id == int(current_user.id), UserProject.user_id.is_not(None)),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.response import success_response
|
||||
from ...dependencies import get_current_user, require_platform_ai_ops
|
||||
from ...models import User
|
||||
from ...services.ai_readiness_service import AIReadinessService
|
||||
|
||||
router = APIRouter(prefix="/ai-readiness", tags=["ai-readiness"])
|
||||
|
||||
|
||||
@router.get("/project-launch")
|
||||
async def get_project_launch_ai_readiness(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
payload = await AIReadinessService(db, tenant_id=int(current_user.tenant_id)).get_project_launch_readiness()
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.post("/project-launch/check")
|
||||
async def run_project_launch_ai_readiness_check(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
payload = await AIReadinessService(db, tenant_id=int(current_user.tenant_id)).run_project_launch_check(
|
||||
user_id=int(current_user.id),
|
||||
)
|
||||
return success_response(payload)
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
"""AI routing and provider governance API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.response import error_response, success_response
|
||||
from ..dependencies.auth import get_current_user
|
||||
from ..dependencies.permissions import require_platform_ai_ops
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import User
|
||||
from ..services.ai_function_runtime_profile_service import AIFunctionRuntimeProfileService
|
||||
from ..services.ai_routing_governance_service import AIRoutingGovernanceService
|
||||
from ..services.auth import can_approve_ai_routing_change, can_view_ai_policy
|
||||
from ..services.operation_log_service import log_operation
|
||||
from ..services.pmflow_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService
|
||||
from ..services.pmflow_launch_service import PMFlowLaunchError, PMFlowLaunchService
|
||||
|
||||
router = APIRouter(prefix="/ai-routing", tags=["ai_routing"])
|
||||
|
||||
|
||||
class TeamPolicySceneUpdate(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=80)
|
||||
policy: str = Field(min_length=1, max_length=300)
|
||||
mode: Optional[str] = Field(default=None, max_length=80)
|
||||
human_confirmation: bool = True
|
||||
protected: bool = True
|
||||
|
||||
|
||||
class TeamPolicyUpdateRequest(BaseModel):
|
||||
strategy: Literal["quality_first", "balanced", "speed_first"] = "balanced"
|
||||
scenes: list[TeamPolicySceneUpdate] = Field(default_factory=list)
|
||||
automation_boundary: Optional[str] = Field(default=None, max_length=300)
|
||||
budget_guard: dict[str, Any] = Field(default_factory=dict)
|
||||
quality_targets: list[str] = Field(default_factory=list, max_length=8)
|
||||
|
||||
|
||||
class CapabilitySlotUpdateRequest(BaseModel):
|
||||
primary_engine_id: Optional[str] = Field(default=None, max_length=120)
|
||||
fallback_engine_ids: list[str] = Field(default_factory=list, max_length=5)
|
||||
failure_policy: Literal["upgrade_to_high", "try_fallback_then_fail", "explicit_fail"] = "try_fallback_then_fail"
|
||||
allow_scene_downgrade: bool = False
|
||||
|
||||
|
||||
class CapabilitySlotPublishRequest(BaseModel):
|
||||
summary: Optional[str] = Field(default=None, max_length=200)
|
||||
|
||||
|
||||
class CapabilitySlotRollbackRequest(BaseModel):
|
||||
target_version: Optional[str] = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class FunctionRuntimeProfileUpdateRequest(BaseModel):
|
||||
max_tokens: Optional[int] = Field(default=None, ge=1, le=20000)
|
||||
repair_max_tokens: Optional[int] = Field(default=None, ge=1, le=20000)
|
||||
temperature: Optional[float] = Field(default=None, ge=0, le=1)
|
||||
foreground_wait_seconds: Optional[int] = Field(default=None, ge=3, le=30)
|
||||
provider_timeout_seconds: Optional[int] = Field(default=None, ge=10, le=300)
|
||||
job_timeout_seconds: Optional[int] = Field(default=None, ge=10, le=900)
|
||||
max_retries: Optional[int] = Field(default=None, ge=0, le=3)
|
||||
large_input_policy: Optional[Literal["direct_generate", "preprocess_then_generate"]] = None
|
||||
response_mode: Optional[str] = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
class FunctionRuntimeProfilePublishRequest(BaseModel):
|
||||
summary: Optional[str] = Field(default=None, max_length=200)
|
||||
|
||||
|
||||
def _forbidden(message: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=403, content=error_response(403, message))
|
||||
|
||||
|
||||
def _service(db: AsyncSession, tenant_id: int) -> AIRoutingGovernanceService:
|
||||
return AIRoutingGovernanceService(db, tenant_id=int(tenant_id))
|
||||
|
||||
|
||||
def _profile_service(db: AsyncSession) -> AIFunctionRuntimeProfileService:
|
||||
return AIFunctionRuntimeProfileService(db)
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_ai_routing_overview(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
data = await _service(db, tenant_id).get_overview()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/team-policy")
|
||||
async def get_ai_team_policy(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not can_view_ai_policy(str(current_user.role or "")):
|
||||
return _forbidden("仅团队负责人可查看团队 AI 策略")
|
||||
data = await _service(db, tenant_id).get_team_policy()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.put("/team-policy")
|
||||
async def update_ai_team_policy(
|
||||
body: TeamPolicyUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not can_approve_ai_routing_change(str(current_user.role or "")):
|
||||
return _forbidden("仅 OWNER 可保存团队 AI 策略")
|
||||
service = _service(db, tenant_id)
|
||||
before = await service.get_team_policy()
|
||||
after = await service.update_team_policy(
|
||||
payload=body.model_dump(mode="json"),
|
||||
updated_by=int(current_user.id),
|
||||
)
|
||||
await log_operation(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(current_user.id),
|
||||
project_id=None,
|
||||
entity_type="ai_routing_policy",
|
||||
entity_id="team-policy",
|
||||
action="update",
|
||||
before_data=before,
|
||||
after_data=after,
|
||||
remark="更新团队 AI 策略",
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(after)
|
||||
|
||||
|
||||
@router.get("/admin-console")
|
||||
async def get_ai_routing_admin_console(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
data = await _service(db, tenant_id).get_admin_console()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/capability-slots")
|
||||
async def get_ai_capability_slots(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
data = await _service(db, tenant_id).get_capability_slots()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.put("/capability-slots/{slot_key}")
|
||||
async def update_ai_capability_slot(
|
||||
slot_key: str,
|
||||
body: CapabilitySlotUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
service = _service(db, tenant_id)
|
||||
before = await service.get_capability_slots()
|
||||
try:
|
||||
after = await service.update_capability_slot(
|
||||
slot_key=slot_key,
|
||||
payload=body.model_dump(mode="json"),
|
||||
updated_by=int(current_user.id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
await log_operation(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(current_user.id),
|
||||
project_id=None,
|
||||
entity_type="ai_capability_slot",
|
||||
entity_id=str(slot_key),
|
||||
action="draft_update",
|
||||
before_data=before,
|
||||
after_data=after,
|
||||
remark="保存 AI 能力槽位草稿",
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(after)
|
||||
|
||||
|
||||
@router.put("/capability-slots/{slot_key}/draft")
|
||||
async def update_ai_capability_slot_draft(
|
||||
slot_key: str,
|
||||
body: CapabilitySlotUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
return await update_ai_capability_slot(
|
||||
slot_key=slot_key,
|
||||
body=body,
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/capability-slots/validate")
|
||||
async def validate_ai_capability_slots(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
data = await _service(db, tenant_id).validate_capability_slots()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/capability-slots/publish")
|
||||
async def publish_ai_capability_slots(
|
||||
body: CapabilitySlotPublishRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
service = _service(db, tenant_id)
|
||||
before = await service.get_capability_slots()
|
||||
try:
|
||||
after = await service.publish_capability_slots(
|
||||
published_by=int(current_user.id),
|
||||
summary=body.summary,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
await log_operation(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(current_user.id),
|
||||
project_id=None,
|
||||
entity_type="ai_capability_slot_release",
|
||||
entity_id=str(after.get("active_version") or ""),
|
||||
action="publish",
|
||||
before_data=before,
|
||||
after_data=after,
|
||||
remark="发布 AI 能力槽位版本",
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(after)
|
||||
|
||||
|
||||
@router.post("/capability-slots/rollback")
|
||||
async def rollback_ai_capability_slots(
|
||||
body: CapabilitySlotRollbackRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
service = _service(db, tenant_id)
|
||||
before = await service.get_capability_slots()
|
||||
try:
|
||||
after = await service.rollback_capability_slots(
|
||||
target_version=body.target_version,
|
||||
rolled_back_by=int(current_user.id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
await log_operation(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(current_user.id),
|
||||
project_id=None,
|
||||
entity_type="ai_capability_slot_release",
|
||||
entity_id=str(after.get("active_version") or ""),
|
||||
action="rollback",
|
||||
before_data=before,
|
||||
after_data=after,
|
||||
remark="回滚 AI 能力槽位版本",
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(after)
|
||||
|
||||
|
||||
@router.get("/function-runtime-profiles")
|
||||
async def get_ai_function_runtime_profiles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
_ = tenant_id
|
||||
data = await _profile_service(db).get_profiles()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.put("/function-runtime-profiles/{function_name}/draft")
|
||||
async def update_ai_function_runtime_profile_draft(
|
||||
function_name: str,
|
||||
body: FunctionRuntimeProfileUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
_ = tenant_id
|
||||
service = _profile_service(db)
|
||||
before = await service.get_profiles()
|
||||
try:
|
||||
after = await service.update_profile(
|
||||
function_name=function_name,
|
||||
payload=body.model_dump(exclude_none=True, mode="json"),
|
||||
updated_by=int(current_user.id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
await log_operation(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(current_user.id),
|
||||
project_id=None,
|
||||
entity_type="ai_function_runtime_profile",
|
||||
entity_id=str(function_name),
|
||||
action="draft_update",
|
||||
before_data=before,
|
||||
after_data=after,
|
||||
remark="保存函数运行合同草稿",
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(after)
|
||||
|
||||
|
||||
@router.post("/function-runtime-profiles/validate")
|
||||
async def validate_ai_function_runtime_profiles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
_ = tenant_id
|
||||
data = await _profile_service(db).get_profiles()
|
||||
return success_response(data.get("validation") or {})
|
||||
|
||||
|
||||
@router.post("/function-runtime-profiles/{function_name}/smoke")
|
||||
async def smoke_ai_function_runtime_profile(
|
||||
function_name: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
profile_service = _profile_service(db)
|
||||
try:
|
||||
if function_name == PMFlowLaunchService.DRAFT_FUNCTION_NAME:
|
||||
draft_payload = await PMFlowLaunchService(db, tenant_id=int(tenant_id)).generate_draft_payload_for_readiness_check(
|
||||
user_id=int(current_user.id),
|
||||
profile_stage="draft",
|
||||
)
|
||||
summary = (
|
||||
f"success: requirements={len(draft_payload.requirements)}, tasks={len(draft_payload.tasks)}, "
|
||||
f"risks={len(draft_payload.risks)}, decisions={len(draft_payload.decisions)}"
|
||||
)
|
||||
elif function_name == PMFlowChangeReviewService.AI_EXTRACTION_FUNCTION_NAME:
|
||||
extraction_payload = await PMFlowChangeReviewService(db, tenant_id=int(tenant_id)).generate_extraction_payload_for_readiness_check(
|
||||
user_id=int(current_user.id),
|
||||
profile_stage="draft",
|
||||
)
|
||||
summary = f"success: proposed_items={len(extraction_payload.get('proposed_items') or [])}"
|
||||
else:
|
||||
raise ValueError("当前函数暂未接入受控 smoke")
|
||||
after = await profile_service.record_smoke(
|
||||
function_name=function_name,
|
||||
status="success",
|
||||
summary=summary,
|
||||
checked_by=int(current_user.id),
|
||||
)
|
||||
except (ValueError, PMFlowLaunchError, PMFlowChangeReviewError, RuntimeError) as exc:
|
||||
after = await profile_service.record_smoke(
|
||||
function_name=function_name,
|
||||
status="failed",
|
||||
summary=str(exc),
|
||||
checked_by=int(current_user.id),
|
||||
)
|
||||
await db.commit()
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc), after))
|
||||
await log_operation(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(current_user.id),
|
||||
project_id=None,
|
||||
entity_type="ai_function_runtime_profile",
|
||||
entity_id=str(function_name),
|
||||
action="smoke",
|
||||
before_data=None,
|
||||
after_data=after,
|
||||
remark="运行函数运行合同 smoke",
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(after)
|
||||
|
||||
|
||||
@router.post("/function-runtime-profiles/publish")
|
||||
async def publish_ai_function_runtime_profiles(
|
||||
body: FunctionRuntimeProfilePublishRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
_ = tenant_id
|
||||
service = _profile_service(db)
|
||||
before = await service.get_profiles()
|
||||
try:
|
||||
after = await service.publish_profiles(
|
||||
published_by=int(current_user.id),
|
||||
summary=body.summary,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
await log_operation(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
user_id=int(current_user.id),
|
||||
project_id=None,
|
||||
entity_type="ai_function_runtime_profile_release",
|
||||
entity_id=str(after.get("active_version") or ""),
|
||||
action="publish",
|
||||
before_data=before,
|
||||
after_data=after,
|
||||
remark="发布函数运行合同版本",
|
||||
)
|
||||
await db.commit()
|
||||
return success_response(after)
|
||||
|
||||
|
||||
@router.post("/diagnostics/run")
|
||||
async def run_ai_routing_diagnostics(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
data = await _service(db, tenant_id).run_diagnostics()
|
||||
return success_response(data)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,46 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest, ProjectFromTextRequest, ProjectFromTextResponse
|
||||
from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest
|
||||
|
||||
@router.post("/project-from-text", response_model=ApiResponse[ProjectFromTextResponse])
|
||||
async def create_project_from_text(
|
||||
request: ProjectFromTextRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id)
|
||||
):
|
||||
"""
|
||||
零配置项目创建 - 从一句话生成完整项目
|
||||
|
||||
示例输入:
|
||||
```
|
||||
我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线
|
||||
```
|
||||
|
||||
AI 将自动生成:
|
||||
- 完整的项目配置
|
||||
- 竞品分析信号
|
||||
- 项目宪章
|
||||
- 任务链(含依赖关系)
|
||||
- 风险预警规则
|
||||
"""
|
||||
try:
|
||||
service = get_ai_steward_service(db)
|
||||
|
||||
result = await service.create_project_from_natural_language(
|
||||
input_text=request.input_text,
|
||||
creator_id=int(current_user.id),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
return success_response(ProjectFromTextResponse(**result).model_dump())
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"项目创建失败: {str(e)}"),
|
||||
)
|
||||
@router.post("/project-from-text")
|
||||
async def create_project_from_text():
|
||||
return JSONResponse(
|
||||
status_code=410,
|
||||
content=error_response(410, "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"),
|
||||
)
|
||||
|
||||
@router.post("/signal/capture-feishu")
|
||||
@audited_write(caller="ai")
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ async def get_executive_briefing(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
|
||||
return _forbidden_response()
|
||||
|
||||
try:
|
||||
|
|
@ -40,7 +40,7 @@ async def get_executive_briefing_config(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
|
||||
return _forbidden_response()
|
||||
payload = await ExecutiveBriefingService(db, tenant_id=tenant_id).get_config()
|
||||
return success_response(payload)
|
||||
|
|
@ -53,7 +53,7 @@ async def update_executive_briefing_config(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||
if not _manager_roles(current_user).intersection({"OWNER", "REVIEWER"}):
|
||||
return _forbidden_response()
|
||||
|
||||
service = ExecutiveBriefingService(db, tenant_id=tenant_id)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from ...services.auth import parse_role_values
|
|||
from ...services.feedback_center import FeedbackCenterService, list_reason_catalog
|
||||
from .shared import *
|
||||
|
||||
MANAGER_ROLES = {"ADMIN", "BOSS"}
|
||||
MANAGER_ROLES = {"REVIEWER", "OWNER"}
|
||||
|
||||
|
||||
def _is_manager_user(current_user: User) -> bool:
|
||||
|
|
|
|||
|
|
@ -2,46 +2,6 @@ from __future__ import annotations
|
|||
|
||||
from .shared import *
|
||||
|
||||
class ProjectFromTextRequest(BaseModel):
|
||||
"""零配置项目创建请求"""
|
||||
input_text: str = Field(
|
||||
...,
|
||||
description="自然语言项目描述,例如:我们要做一个 AI 客服工具,基于 GPT-4o mini,对标 XX 竞品,3 周上线",
|
||||
min_length=10,
|
||||
max_length=500
|
||||
)
|
||||
|
||||
class ProjectFromTextSignalSource(BaseModel):
|
||||
registry_id: int | str | None = None
|
||||
id: int | str | None = None
|
||||
project_id: int | str | None = None
|
||||
signal_id: int | str | None = None
|
||||
source_kind: str
|
||||
source_name: str
|
||||
source_identifier: str
|
||||
display_source_kind: Optional[str] = None
|
||||
technical_identifier: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
requested_via: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
onboarding_status: Optional[str] = None
|
||||
section: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
origin: Optional[str] = None
|
||||
raw_context: Optional[Dict[str, Any]] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
class ProjectFromTextResponse(BaseModel):
|
||||
"""零配置项目创建响应"""
|
||||
project: Dict[str, Any]
|
||||
signal: Dict[str, Any]
|
||||
charter: Dict[str, Any]
|
||||
tasks: List[Dict[str, Any]]
|
||||
risks: List[Dict[str, Any]]
|
||||
signal_sources: List[ProjectFromTextSignalSource] = Field(default_factory=list)
|
||||
analysis: Dict[str, Any]
|
||||
|
||||
class CognitiveConflictResponse(BaseModel):
|
||||
"""认知冲突响应"""
|
||||
id: str
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ async def list_audit_operations(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
header_tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
if current_user is not None and not has_any_role(_user_role(current_user), ["BOSS", "ADMIN"]):
|
||||
raise HTTPException(status_code=403, detail="Access denied. Required roles: BOSS, ADMIN")
|
||||
if current_user is not None and not has_any_role(_user_role(current_user), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="Access denied. Required roles: OWNER, REVIEWER")
|
||||
if current_user is None:
|
||||
effective_tenant_id = int(header_tenant_id or 1)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,14 +1,23 @@
|
|||
"""认证 API"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..models import User, UserStatus
|
||||
from ..schemas import UserLoginResponse
|
||||
from ..schemas import PhoneCodeSendRequest, PhoneCodeVerifyRequest, UserLoginResponse, UserRegisterRequest
|
||||
from ..services.auth import AuthService
|
||||
from ..services.phone_auth_service import send_phone_verification_code, verify_phone_and_login
|
||||
from ..services.platform_access_service import get_platform_context
|
||||
from ..services.workspace_service import (
|
||||
ensure_active_workspace_membership,
|
||||
ensure_personal_workspace,
|
||||
list_user_workspaces,
|
||||
register_user_with_personal_workspace,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
|
@ -20,7 +29,7 @@ async def login(
|
|||
):
|
||||
"""用户登录,返回 JWT"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == credentials.username)
|
||||
select(User).where(or_(User.username == credentials.username, User.phone_e164 == credentials.username))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -39,10 +48,76 @@ async def login(
|
|||
detail="User is disabled",
|
||||
)
|
||||
|
||||
await ensure_personal_workspace(db, user=user, commit=False)
|
||||
await ensure_active_workspace_membership(db, user=user, commit=False)
|
||||
await db.commit()
|
||||
access_token = AuthService.create_access_token(data={"sub": user.username})
|
||||
workspaces = await list_user_workspaces(db, user=user)
|
||||
platform_context = await get_platform_context(db, user=user)
|
||||
|
||||
return UserLoginResponse(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
user=user.to_dict(),
|
||||
user={**user.to_dict(), "workspace_context": workspaces, "platform_context": platform_context},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/phone/send-code")
|
||||
async def send_phone_code(
|
||||
body: PhoneCodeSendRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""发送手机号验证码。开发环境会返回 dev_code,生产环境接短信服务商后不返回验证码。"""
|
||||
return await send_phone_verification_code(
|
||||
db,
|
||||
country_code=body.phone_country_code,
|
||||
phone_number=body.phone_number,
|
||||
purpose=body.purpose,
|
||||
request_ip=request.client.host if request.client else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/phone/verify", response_model=UserLoginResponse)
|
||||
async def verify_phone_code_login(
|
||||
body: PhoneCodeVerifyRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""手机号验证码登录/自注册,并可绑定邀请。"""
|
||||
result = await verify_phone_and_login(
|
||||
db,
|
||||
country_code=body.phone_country_code,
|
||||
phone_number=body.phone_number,
|
||||
code=body.code,
|
||||
purpose=body.purpose,
|
||||
invite_token=body.invite_token,
|
||||
name=body.name,
|
||||
)
|
||||
return UserLoginResponse(
|
||||
access_token=result["access_token"],
|
||||
token_type=result["token_type"],
|
||||
user=result["user"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserLoginResponse)
|
||||
async def register(
|
||||
body: UserRegisterRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""注册账号并自动创建个人空间。"""
|
||||
user = await register_user_with_personal_workspace(
|
||||
db,
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
name=body.name,
|
||||
email=body.email or f"{body.username}@placeholder.local",
|
||||
)
|
||||
access_token = AuthService.create_access_token(data={"sub": user.username})
|
||||
workspaces = await list_user_workspaces(db, user=user)
|
||||
platform_context = await get_platform_context(db, user=user)
|
||||
return UserLoginResponse(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
user={**user.to_dict(), "workspace_context": workspaces, "platform_context": platform_context},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ async def list_budgets(
|
|||
@router.post("/")
|
||||
async def create_budget(
|
||||
request: BudgetCreateRequest,
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -122,7 +122,7 @@ async def create_budget(
|
|||
async def update_budget(
|
||||
budget_id: int,
|
||||
request: BudgetUpdateRequest,
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -163,7 +163,7 @@ async def update_budget(
|
|||
async def override_budget(
|
||||
budget_id: int,
|
||||
request: BudgetOverrideRequest,
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.response import success_response
|
||||
from ..dependencies import get_current_user
|
||||
from ..models import Project, User, UserProject
|
||||
from ..services.ai_project_control_service import AIProjectControlService
|
||||
from ..services.auth import has_any_role
|
||||
|
||||
router = APIRouter(tags=["dev-sync"])
|
||||
|
||||
|
||||
class RepoBindingCreateRequest(BaseModel):
|
||||
project_id: int
|
||||
provider: str = "github"
|
||||
owner: str
|
||||
repo_name: str
|
||||
default_branch: str = "main"
|
||||
external_repo_id: str | None = None
|
||||
installation_id: str | None = None
|
||||
installation_account_login: str | None = None
|
||||
webhook_secret_hint: str | None = None
|
||||
|
||||
|
||||
class RepoBindingSelectRequest(BaseModel):
|
||||
project_id: int
|
||||
github_repository_id: int
|
||||
|
||||
|
||||
class ProgressEventCreateRequest(BaseModel):
|
||||
project_id: int
|
||||
event_type: str
|
||||
source: str = "manual"
|
||||
summary: str | None = None
|
||||
repo_binding_id: int | None = None
|
||||
workspace_binding_id: int | None = None
|
||||
payload: dict[str, object] | None = None
|
||||
occurred_at: str | None = None
|
||||
|
||||
|
||||
@router.get("/integrations/github/app/install-url")
|
||||
async def get_github_app_install_url(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
payload = await control.get_github_app_install_center()
|
||||
return success_response(
|
||||
{
|
||||
"install_url": payload.get("install_url"),
|
||||
"configured": bool(payload.get("configured")),
|
||||
"callback_url": payload.get("callback_url"),
|
||||
"webhook_signature_configured": bool(payload.get("webhook_signature_configured")),
|
||||
"app_slug": payload.get("app_slug"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/integrations/github/app/installations")
|
||||
async def list_github_app_installations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
payload = await control.get_github_app_install_center()
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.post("/integrations/github/app/installations/sync")
|
||||
async def sync_github_app_installations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
payload = await control.sync_github_installations()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.get("/integrations/github/repos/available")
|
||||
async def list_available_github_repos(
|
||||
project_id: int | None = Query(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if project_id is not None:
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
return success_response(await control.list_available_github_repositories())
|
||||
|
||||
|
||||
async def _ensure_project_member_or_manager(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
current_user: User,
|
||||
) -> None:
|
||||
project_result = await db.execute(
|
||||
select(Project).where(and_(Project.id == int(project_id), Project.tenant_id == int(tenant_id)))
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
if has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]):
|
||||
return
|
||||
member_result = await db.execute(
|
||||
select(UserProject).where(
|
||||
and_(
|
||||
UserProject.tenant_id == int(tenant_id),
|
||||
UserProject.project_id == int(project_id),
|
||||
UserProject.user_id == int(current_user.id),
|
||||
)
|
||||
)
|
||||
)
|
||||
if member_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=403, detail="当前用户无权操作该项目")
|
||||
|
||||
|
||||
@router.get("/me/dev-tasks")
|
||||
async def list_my_dev_tasks(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
return success_response(await control.get_dev_tasks_for_user(user_id=int(current_user.id)))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/dev-sync/summary")
|
||||
async def get_project_dev_sync_summary(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
return success_response(
|
||||
await control.get_project_dev_sync_summary(
|
||||
project_id=int(project_id),
|
||||
current_user_id=int(current_user.id),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/progress-events")
|
||||
async def create_progress_event(
|
||||
task_id: int,
|
||||
body: ProgressEventCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(body.project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
payload = await control.create_or_apply_progress_event(
|
||||
task_id=int(task_id),
|
||||
project_id=int(body.project_id),
|
||||
event_type=body.event_type,
|
||||
source=body.source,
|
||||
payload=dict(body.payload or {}),
|
||||
summary=body.summary,
|
||||
user_id=int(current_user.id),
|
||||
repo_binding_id=int(body.repo_binding_id) if body.repo_binding_id is not None else None,
|
||||
workspace_binding_id=int(body.workspace_binding_id) if body.workspace_binding_id is not None else None,
|
||||
occurred_at=body.occurred_at,
|
||||
)
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.post("/internal/debug/integrations/github/repos")
|
||||
async def register_github_repo(
|
||||
body: RepoBindingCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(body.project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
payload = await control.register_repo_binding(
|
||||
project_id=int(body.project_id),
|
||||
provider=body.provider,
|
||||
owner=body.owner,
|
||||
repo_name=body.repo_name,
|
||||
default_branch=body.default_branch,
|
||||
external_repo_id=body.external_repo_id,
|
||||
installation_id=body.installation_id,
|
||||
installation_account_login=body.installation_account_login,
|
||||
webhook_secret_hint=body.webhook_secret_hint,
|
||||
)
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.post("/integrations/github/repos/bind")
|
||||
async def bind_github_repo(
|
||||
body: RepoBindingSelectRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(body.project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
payload = await control.bind_github_repository(
|
||||
project_id=int(body.project_id),
|
||||
github_repository_id=int(body.github_repository_id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@router.get("/integrations/github/deliveries")
|
||||
async def list_github_deliveries(
|
||||
project_id: int | None = Query(default=None),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if project_id is not None:
|
||||
await _ensure_project_member_or_manager(
|
||||
db,
|
||||
tenant_id=int(current_user.tenant_id),
|
||||
project_id=int(project_id),
|
||||
current_user=current_user,
|
||||
)
|
||||
control = AIProjectControlService(db, tenant_id=int(current_user.tenant_id))
|
||||
return success_response(await control.list_github_deliveries(project_id=project_id, limit=limit))
|
||||
|
||||
|
||||
@router.post("/integrations/github/webhook")
|
||||
async def ingest_github_webhook(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
event_type: str | None = Header(default=None, alias="X-GitHub-Event"),
|
||||
signature_256: str | None = Header(default=None, alias="X-Hub-Signature-256"),
|
||||
delivery_id: str | None = Header(default=None, alias="X-GitHub-Delivery"),
|
||||
):
|
||||
raw_body = await request.body()
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="GitHub webhook body 不是合法 JSON") from exc
|
||||
tenant_id = int((payload.get("tenant_id") or 1))
|
||||
control = AIProjectControlService(db, tenant_id=tenant_id)
|
||||
try:
|
||||
result = await control.ingest_github_webhook(
|
||||
event_type=str(event_type or payload.get("event_type") or "").strip(),
|
||||
delivery_id=str(delivery_id or payload.get("delivery_id") or "").strip(),
|
||||
signature_256=signature_256,
|
||||
payload=payload,
|
||||
raw_body=raw_body,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.post("/internal/debug/integrations/github/events")
|
||||
async def ingest_github_event_debug(
|
||||
payload: dict = Body(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
event_type: str | None = Header(default=None, alias="X-GitHub-Event"),
|
||||
):
|
||||
tenant_id = int((payload.get("tenant_id") or 1))
|
||||
control = AIProjectControlService(db, tenant_id=tenant_id)
|
||||
try:
|
||||
result = await control.ingest_github_event(
|
||||
event_type=str(event_type or payload.get("event_type") or "").strip(),
|
||||
payload=payload,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return success_response({"debug": True, **result})
|
||||
|
|
@ -27,5 +27,5 @@ def skill_tag_to_response(tag) -> SkillTagResponse:
|
|||
|
||||
def ensure_self_or_admin(*, current_user, target_user_id: int, detail: str) -> None:
|
||||
current_role = current_user.role.value if isinstance(current_user.role, Enum) else current_user.role
|
||||
if current_user.id != target_user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
|
||||
if current_user.id != target_user_id and not has_any_role(str(current_role), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ async def list_skill_tags(
|
|||
async def create_skill_tag(
|
||||
body: SkillTagCreate,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -78,7 +78,7 @@ async def update_skill_tag(
|
|||
tag_id: UUID,
|
||||
body: SkillTagUpdate,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -103,7 +103,7 @@ async def update_skill_tag(
|
|||
async def delete_skill_tag(
|
||||
tag_id: UUID,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
|
|||
|
|
@ -0,0 +1,376 @@
|
|||
"""Feishu Integration API - 飞书集成接口。"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from ..database import get_db
|
||||
from ..dependencies.auth import get_current_user
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import User
|
||||
from ..services.auth import has_any_role
|
||||
from ..services.feishu_integration import get_feishu_service
|
||||
from ..services.feishu_long_connection import get_feishu_long_connection_manager
|
||||
|
||||
|
||||
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
|
||||
|
||||
|
||||
def _build_long_connection_failure(long_status: Dict[str, Any]) -> tuple[str, Optional[str]]:
|
||||
code = str(long_status.get("last_error_code") or "").strip().lower()
|
||||
last_error = str(long_status.get("last_error") or "").strip()
|
||||
retryable = bool(long_status.get("last_error_retryable"))
|
||||
if code in {"network_error", "thread_exited"} and not retryable:
|
||||
return (
|
||||
"长连接自动重连次数已耗尽,请检查网络/凭证后手动重启飞书长连接",
|
||||
last_error or None,
|
||||
)
|
||||
if code == "auth_failed":
|
||||
return (
|
||||
"长连接鉴权失败,请检查 App ID / App Secret、应用凭证是否已生效,以及飞书应用权限是否完整",
|
||||
last_error or None,
|
||||
)
|
||||
if code == "network_error":
|
||||
return (
|
||||
"长连接预检失败,后端到飞书开放平台的网络或 TLS 证书链异常",
|
||||
last_error or None,
|
||||
)
|
||||
if code == "sdk_missing":
|
||||
return ("长连接 SDK 未安装(缺少 lark-oapi)", None)
|
||||
if code == "thread_exited":
|
||||
return ("长连接已退出,系统正在等待下一次自动重连窗口", last_error or None)
|
||||
if last_error:
|
||||
return ("长连接未建立,请检查配置与后端日志", last_error)
|
||||
return ("长连接未建立,请检查配置后重试", None)
|
||||
|
||||
|
||||
# ==================== Request/Response Models ====================
|
||||
|
||||
class FeishuConfigRequest(BaseModel):
|
||||
"""飞书配置请求"""
|
||||
app_id: str = Field(..., description="飞书 App ID")
|
||||
app_secret: str = Field(..., description="飞书 App Secret")
|
||||
encrypt_key: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
|
||||
verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
|
||||
event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection")
|
||||
|
||||
|
||||
class FeishuSendMessageRequest(BaseModel):
|
||||
"""发送消息请求"""
|
||||
chat_id: str = Field(..., description="群聊 ID")
|
||||
message: str = Field(..., description="消息内容")
|
||||
|
||||
|
||||
class FeishuSendCardRequest(BaseModel):
|
||||
"""发送卡片请求"""
|
||||
chat_id: str = Field(..., description="群聊 ID")
|
||||
title: str = Field(..., description="卡片标题")
|
||||
content: List[Dict[str, Any]] = Field(..., description="卡片内容")
|
||||
|
||||
|
||||
# ==================== Configuration Endpoints ====================
|
||||
|
||||
@router.post("/config")
|
||||
async def save_feishu_config(
|
||||
request: FeishuConfigRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id)
|
||||
):
|
||||
"""
|
||||
保存飞书配置
|
||||
|
||||
配置信息说明:
|
||||
- app_id: 飞书应用的 App ID,从飞书开放平台获取
|
||||
- app_secret: 飞书应用的 App Secret
|
||||
|
||||
获取步骤:
|
||||
1. 访问 https://open.feishu.cn/
|
||||
2. 创建企业自建应用
|
||||
3. 在「凭证与基础信息」页面获取 App ID 和 App Secret
|
||||
4. 添加权限:im:message, im:message:group_at_msg, im:chat
|
||||
5. 订阅事件:接收消息 v1 (message.receive_v1)
|
||||
"""
|
||||
# 检查用户权限
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
|
||||
|
||||
try:
|
||||
event_mode = "long_connection"
|
||||
|
||||
service = get_feishu_service(db)
|
||||
await service.save_config(
|
||||
app_id=request.app_id,
|
||||
app_secret=request.app_secret,
|
||||
encrypt_key="",
|
||||
verification_token="",
|
||||
event_mode=event_mode,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
manager = get_feishu_long_connection_manager()
|
||||
if manager:
|
||||
await manager.restart()
|
||||
|
||||
next_steps = [
|
||||
"1. 飞书应用事件订阅至少包含:接收消息 v1 (message.receive_v1)",
|
||||
"2. 当前模式:long_connection",
|
||||
"3. 后端已自动重启飞书长连接",
|
||||
]
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': '飞书配置已保存,并已切换到长连接模式',
|
||||
'next_steps': next_steps
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"保存配置失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_feishu_config(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id)
|
||||
):
|
||||
"""获取飞书配置状态。管理员可获取完整配置用于回填修改表单。"""
|
||||
try:
|
||||
service = get_feishu_service(db)
|
||||
await service.load_config(tenant_id)
|
||||
manager = get_feishu_long_connection_manager()
|
||||
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
|
||||
|
||||
return {
|
||||
'is_configured': service.is_configured(),
|
||||
'app_id': service.app_id,
|
||||
'app_secret': service.app_secret if can_view_secrets else None,
|
||||
'event_mode': "long_connection",
|
||||
'long_connection': manager.status() if manager else None,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取配置失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/config")
|
||||
async def delete_feishu_config(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id)
|
||||
):
|
||||
"""删除飞书配置"""
|
||||
from ..models import Integration
|
||||
from sqlalchemy.future import select
|
||||
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Integration).where(
|
||||
Integration.tenant_id == tenant_id,
|
||||
Integration.type == 'feishu'
|
||||
)
|
||||
)
|
||||
integration = result.scalar_one_or_none()
|
||||
|
||||
if integration:
|
||||
integration.is_active = False
|
||||
await db.commit()
|
||||
manager = get_feishu_long_connection_manager()
|
||||
if manager:
|
||||
await manager.restart()
|
||||
|
||||
return {'status': 'success', 'message': '飞书配置已删除'}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"删除配置失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/long-connection/status")
|
||||
async def feishu_long_connection_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""查询飞书长连接状态。"""
|
||||
manager = get_feishu_long_connection_manager()
|
||||
if not manager:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Feishu 长连接管理器未初始化",
|
||||
"mode": "long_connection",
|
||||
"data": None,
|
||||
}
|
||||
return {
|
||||
"status": "success",
|
||||
"mode": "long_connection",
|
||||
"data": manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/long-connection/restart")
|
||||
async def restart_feishu_long_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""手动重启飞书长连接。"""
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
|
||||
|
||||
manager = get_feishu_long_connection_manager()
|
||||
if not manager:
|
||||
raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化")
|
||||
|
||||
await manager.restart()
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Feishu 长连接已重启",
|
||||
"mode": "long_connection",
|
||||
"data": manager.status(),
|
||||
}
|
||||
|
||||
# ==================== Message Endpoints ====================
|
||||
|
||||
@router.post("/send-message")
|
||||
async def send_feishu_message(
|
||||
request: FeishuSendMessageRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id)
|
||||
):
|
||||
"""
|
||||
发送文本消息到飞书群
|
||||
|
||||
用于发送简报、通知等
|
||||
"""
|
||||
try:
|
||||
service = get_feishu_service(db)
|
||||
await service.load_config(tenant_id)
|
||||
|
||||
result = await service.send_message_to_group(
|
||||
chat_id=request.chat_id,
|
||||
message=request.message
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'result': result
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"发送消息失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/send-card")
|
||||
async def send_feishu_card(
|
||||
request: FeishuSendCardRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id)
|
||||
):
|
||||
"""
|
||||
发送富文本卡片到飞书群
|
||||
|
||||
用于发送格式化的每日简报、风险预警等
|
||||
"""
|
||||
try:
|
||||
service = get_feishu_service(db)
|
||||
await service.load_config(tenant_id)
|
||||
|
||||
result = await service.send_rich_card(
|
||||
chat_id=request.chat_id,
|
||||
title=request.title,
|
||||
content=request.content
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'result': result
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"发送卡片失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ==================== Test Endpoints ====================
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_feishu_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id)
|
||||
):
|
||||
"""测试飞书连接是否正常"""
|
||||
try:
|
||||
service = get_feishu_service(db)
|
||||
await service.load_config(tenant_id)
|
||||
manager = get_feishu_long_connection_manager()
|
||||
|
||||
if not service.is_configured():
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': '飞书未配置'
|
||||
}
|
||||
|
||||
# 尝试获取 tenant_access_token 测试连接
|
||||
try:
|
||||
access_token = await service._get_tenant_access_token()
|
||||
event_mode = "long_connection"
|
||||
long_status = manager.status() if manager else None
|
||||
|
||||
if not long_status:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': '长连接管理器未初始化',
|
||||
'event_mode': event_mode,
|
||||
}
|
||||
if not long_status.get("sdk_available"):
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': '长连接 SDK 未安装(缺少 lark-oapi)',
|
||||
'event_mode': event_mode,
|
||||
'long_connection': long_status,
|
||||
}
|
||||
if not long_status.get("thread_alive"):
|
||||
message, detail = _build_long_connection_failure(long_status)
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': message,
|
||||
'event_mode': event_mode,
|
||||
'long_connection': long_status,
|
||||
'detail': detail,
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': '飞书连接正常',
|
||||
'has_token': bool(access_token),
|
||||
'mode': 'long_connection',
|
||||
'event_mode': event_mode,
|
||||
'long_connection': long_status,
|
||||
}
|
||||
except Exception as token_error:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': f'获取 Token 失败: {str(token_error)}',
|
||||
'hint': '请检查 App ID 和 App Secret 是否正确'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"测试连接失败: {str(e)}"
|
||||
)
|
||||
|
|
@ -39,7 +39,7 @@ async def save_feishu_config(
|
|||
4. 添加权限:im:message, im:message:group_at_msg, im:chat
|
||||
5. 订阅事件:接收消息 v1 (message.receive_v1)
|
||||
"""
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
|
||||
|
||||
try:
|
||||
|
|
@ -85,7 +85,7 @@ async def get_feishu_config(
|
|||
service = get_feishu_service(db)
|
||||
await service.load_config(tenant_id)
|
||||
manager = get_feishu_long_connection_manager()
|
||||
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
|
||||
can_view_secrets = has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"])
|
||||
|
||||
return {
|
||||
"is_configured": service.is_configured(),
|
||||
|
|
@ -105,7 +105,7 @@ async def delete_feishu_config(
|
|||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""删除飞书配置。"""
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ async def restart_feishu_long_connection(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""手动重启飞书长连接。"""
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["REVIEWER", "OWNER"]):
|
||||
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
|
||||
|
||||
manager = get_feishu_long_connection_manager()
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class VerifyContextConsumptionRequest(BaseModel):
|
|||
|
||||
|
||||
def _require_manager(user: User) -> None:
|
||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from ..core.response import error_response
|
||||
|
||||
router = APIRouter(prefix="/ai-steward", tags=["AI Steward Legacy"])
|
||||
|
||||
|
||||
@router.post("/project-from-text")
|
||||
async def create_project_from_text_legacy_gone():
|
||||
return JSONResponse(
|
||||
status_code=410,
|
||||
content=error_response(410, "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"),
|
||||
)
|
||||
|
|
@ -10,10 +10,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from ..core.response import success_response
|
||||
from ..core.database import get_db
|
||||
from ..dependencies.auth import get_current_user
|
||||
from ..dependencies.permissions import require_platform_ai_ops
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import User
|
||||
from ..services.auth import has_any_role
|
||||
from ..services.llm_service import llm_service
|
||||
|
||||
router = APIRouter(prefix="/ai-steward/llm-engines", tags=["AI Steward"])
|
||||
|
|
@ -21,37 +20,78 @@ router = APIRouter(prefix="/ai-steward/llm-engines", tags=["AI Steward"])
|
|||
|
||||
class LLMEngineCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=80)
|
||||
provider: Literal["deepseek", "glm", "openai", "ollama"]
|
||||
provider: Literal["deepseek", "dashscope", "openai", "zhipu", "ollama"]
|
||||
api_key: Optional[str] = Field(default="", max_length=500)
|
||||
model: Optional[str] = Field(default=None, max_length=120)
|
||||
base_url: Optional[str] = Field(default=None, max_length=255)
|
||||
set_active: bool = True
|
||||
capabilities: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class LLMEngineUpdateRequest(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=80)
|
||||
provider: Optional[Literal["deepseek", "glm", "openai", "ollama"]] = None
|
||||
provider: Optional[Literal["deepseek", "dashscope", "openai", "zhipu", "ollama"]] = None
|
||||
api_key: Optional[str] = Field(default=None, max_length=500)
|
||||
model: Optional[str] = Field(default=None, max_length=120)
|
||||
base_url: Optional[str] = Field(default=None, max_length=255)
|
||||
capabilities: Optional[dict[str, object]] = None
|
||||
|
||||
|
||||
class LLMEngineTestRequest(BaseModel):
|
||||
prompt: Optional[str] = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class LLMEngineCapabilityProbeRequest(BaseModel):
|
||||
mode: Literal["quick", "full", "advanced"] = "full"
|
||||
targets: list[
|
||||
Literal[
|
||||
"basic",
|
||||
"chat",
|
||||
"basic_chat",
|
||||
"model_self_report",
|
||||
"self_report",
|
||||
"model_report",
|
||||
"structured",
|
||||
"structured_output",
|
||||
"json",
|
||||
"json_schema",
|
||||
"tool",
|
||||
"tool_calling",
|
||||
"function_calling",
|
||||
"stream",
|
||||
"streaming",
|
||||
"long_context",
|
||||
"long_context_32k",
|
||||
"long_context_128k",
|
||||
"vision",
|
||||
"vision_input",
|
||||
"audio",
|
||||
"audio_input",
|
||||
]
|
||||
] = Field(default_factory=list, max_length=12)
|
||||
allow_high_cost: bool = False
|
||||
|
||||
|
||||
class LLMEngineCapabilityAttestationRequest(BaseModel):
|
||||
capability_key: Literal["long_context"] = "long_context"
|
||||
tested_tokens: int = Field(..., gt=0, le=10_000_000)
|
||||
source_type: Literal["official_doc", "provider_console", "contract", "manual_verification"] = "manual_verification"
|
||||
source_url: Optional[str] = Field(default=None, max_length=500)
|
||||
source_note: Optional[str] = Field(default=None, max_length=500)
|
||||
expires_at: Optional[str] = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class LLMEngineSettingsUpdateRequest(BaseModel):
|
||||
enable_local_kb_qa: bool = False
|
||||
|
||||
|
||||
def _require_manager(user: User) -> None:
|
||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置")
|
||||
class LLMEngineEnabledUpdateRequest(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_llm_engines(
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
|
|
@ -63,11 +103,10 @@ async def list_llm_engines(
|
|||
@router.post("")
|
||||
async def create_llm_engine(
|
||||
body: LLMEngineCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_require_manager(current_user)
|
||||
try:
|
||||
engine = await llm_service.create_engine(
|
||||
db,
|
||||
|
|
@ -78,6 +117,7 @@ async def create_llm_engine(
|
|||
model=body.model,
|
||||
base_url=body.base_url,
|
||||
set_active=body.set_active,
|
||||
capabilities=body.capabilities,
|
||||
)
|
||||
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||
return success_response({"engine": engine, **all_items})
|
||||
|
|
@ -88,11 +128,10 @@ async def create_llm_engine(
|
|||
@router.put("/settings")
|
||||
async def update_llm_engine_settings(
|
||||
body: LLMEngineSettingsUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_require_manager(current_user)
|
||||
try:
|
||||
settings = await llm_service.update_engine_settings(
|
||||
db,
|
||||
|
|
@ -109,11 +148,10 @@ async def update_llm_engine_settings(
|
|||
async def update_llm_engine(
|
||||
engine_id: str,
|
||||
body: LLMEngineUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_require_manager(current_user)
|
||||
try:
|
||||
engine = await llm_service.update_engine(
|
||||
db,
|
||||
|
|
@ -124,6 +162,7 @@ async def update_llm_engine(
|
|||
api_key=body.api_key,
|
||||
model=body.model,
|
||||
base_url=body.base_url,
|
||||
capabilities=body.capabilities,
|
||||
)
|
||||
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||
return success_response({"engine": engine, **all_items})
|
||||
|
|
@ -134,13 +173,12 @@ async def update_llm_engine(
|
|||
@router.post("/{engine_id}/activate")
|
||||
async def activate_llm_engine(
|
||||
engine_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_require_manager(current_user)
|
||||
try:
|
||||
result = await llm_service.activate_engine(
|
||||
result = await llm_service.set_default_fallback_engine(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
engine_id=engine_id,
|
||||
|
|
@ -151,20 +189,60 @@ async def activate_llm_engine(
|
|||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{engine_id}/default-fallback")
|
||||
async def set_llm_engine_default_fallback(
|
||||
engine_id: str,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
try:
|
||||
result = await llm_service.set_default_fallback_engine(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
engine_id=engine_id,
|
||||
)
|
||||
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||
return success_response({**result, **all_items})
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/{engine_id}/enabled")
|
||||
async def update_llm_engine_enabled(
|
||||
engine_id: str,
|
||||
body: LLMEngineEnabledUpdateRequest,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
try:
|
||||
result = await llm_service.set_engine_enabled(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
engine_id=engine_id,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||
return success_response({**result, **all_items})
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/test-active")
|
||||
async def test_active_llm_engine(
|
||||
body: LLMEngineTestRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_require_manager(current_user)
|
||||
try:
|
||||
data = await llm_service.test_engine_connection(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
engine_id=None,
|
||||
prompt=body.prompt,
|
||||
user_id=int(current_user.id),
|
||||
)
|
||||
return success_response(data)
|
||||
except ValueError as exc:
|
||||
|
|
@ -175,31 +253,81 @@ async def test_active_llm_engine(
|
|||
async def test_llm_engine(
|
||||
engine_id: str,
|
||||
body: LLMEngineTestRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_require_manager(current_user)
|
||||
try:
|
||||
data = await llm_service.test_engine_connection(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
engine_id=engine_id,
|
||||
prompt=body.prompt,
|
||||
user_id=int(current_user.id),
|
||||
)
|
||||
return success_response(data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{engine_id}/capability-probe")
|
||||
async def probe_llm_engine_capabilities(
|
||||
engine_id: str,
|
||||
body: LLMEngineCapabilityProbeRequest,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
try:
|
||||
data = await llm_service.probe_engine_capabilities(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
engine_id=engine_id,
|
||||
targets=body.targets,
|
||||
mode=body.mode,
|
||||
allow_high_cost=body.allow_high_cost,
|
||||
user_id=int(current_user.id),
|
||||
)
|
||||
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||
return success_response({"probe": data, **all_items})
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{engine_id}/capability-attest")
|
||||
async def attest_llm_engine_capability(
|
||||
engine_id: str,
|
||||
body: LLMEngineCapabilityAttestationRequest,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
try:
|
||||
engine = await llm_service.attest_engine_capability(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
engine_id=engine_id,
|
||||
capability_key=body.capability_key,
|
||||
tested_tokens=body.tested_tokens,
|
||||
source_type=body.source_type,
|
||||
source_url=body.source_url,
|
||||
source_note=body.source_note,
|
||||
expires_at=body.expires_at,
|
||||
user_id=int(current_user.id),
|
||||
)
|
||||
all_items = await llm_service.list_engines(db, tenant_id=tenant_id)
|
||||
return success_response({"engine": engine, **all_items})
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/{engine_id}")
|
||||
async def delete_llm_engine(
|
||||
engine_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_require_manager(current_user)
|
||||
try:
|
||||
result = await llm_service.delete_engine(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -20,25 +20,31 @@ def _build_provider_payload() -> dict[str, Any]:
|
|||
low_tier = dict(tier_defaults.get("low") or {})
|
||||
high_tier = dict(tier_defaults.get("high") or {})
|
||||
openai_defaults = dict(provider_defaults.get("openai") or {})
|
||||
glm_defaults = dict(provider_defaults.get("glm") or {})
|
||||
dashscope_defaults = dict(provider_defaults.get("dashscope") or {})
|
||||
zhipu_defaults = dict(provider_defaults.get("zhipu") or {})
|
||||
deepseek_defaults = dict(provider_defaults.get("deepseek") or {})
|
||||
ollama_defaults = dict(provider_defaults.get("ollama") or {})
|
||||
high_model = str(high_tier.get("model") or openai_defaults.get("model") or "GLM-5").strip()
|
||||
return {
|
||||
"low": str(low_tier.get("model") or glm_defaults.get("model") or "qwen-plus").strip(),
|
||||
"low": str(low_tier.get("model") or dashscope_defaults.get("model") or "qwen-plus").strip(),
|
||||
"high": high_model,
|
||||
"base_url": str(high_tier.get("base_url") or openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(),
|
||||
"providers": {
|
||||
"glm": {
|
||||
"model": str(glm_defaults.get("model") or "qwen-plus").strip(),
|
||||
"base_url": str(glm_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(),
|
||||
"api_env": str(glm_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(),
|
||||
"dashscope": {
|
||||
"model": str(dashscope_defaults.get("model") or "qwen-plus").strip(),
|
||||
"base_url": str(dashscope_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(),
|
||||
"api_env": str(dashscope_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(),
|
||||
},
|
||||
"openai": {
|
||||
"model": high_model,
|
||||
"base_url": str(openai_defaults.get("base_url") or "https://api.edgefn.net/v1").strip(),
|
||||
"api_env": str(openai_defaults.get("api_env") or "OPENAI_API_KEY").strip(),
|
||||
},
|
||||
"zhipu": {
|
||||
"model": str(zhipu_defaults.get("model") or "glm-5.1").strip(),
|
||||
"base_url": str(zhipu_defaults.get("base_url") or "https://open.bigmodel.cn/api/paas/v4").strip(),
|
||||
"api_env": str(zhipu_defaults.get("api_env") or "ZHIPU_API_KEY").strip(),
|
||||
},
|
||||
"deepseek": {
|
||||
"model": str(deepseek_defaults.get("model") or "deepseek-chat").strip(),
|
||||
"base_url": str(deepseek_defaults.get("base_url") or "https://api.deepseek.com/v1").strip(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,491 @@
|
|||
"""模型能力矩阵 API"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.response import ErrorCode, error_response, success_response
|
||||
from ..database import get_db
|
||||
from ..dependencies import get_current_user, get_tenant_id
|
||||
from ..models import Experiment, TruthNode, User
|
||||
from ..schemas.model_capability import (
|
||||
CostOptimizationAnalyzeRequest,
|
||||
ExperimentSyncRequest,
|
||||
ExperimentSyncReviewRequest,
|
||||
ExperimentSyncRollbackRequest,
|
||||
GenerateMigrationTasksRequest,
|
||||
LifecycleImpactRequest,
|
||||
ValidateMatchRequest,
|
||||
)
|
||||
from ..services.ai_service import get_ai_service
|
||||
from ..services.cost_optimization_service import CostOptimizationService
|
||||
from ..services.experiment_sync_service import ExperimentSyncService
|
||||
from ..services.model_capability_service import ModelCapabilityService
|
||||
from ..services.model_lifecycle_service import ModelLifecycleService
|
||||
|
||||
router = APIRouter(prefix="/model-capability", tags=["model-capability"])
|
||||
|
||||
|
||||
def _build_service(db: AsyncSession) -> ModelCapabilityService:
|
||||
return ModelCapabilityService(db=db, ai_service=get_ai_service())
|
||||
|
||||
|
||||
@router.post("/validate-match")
|
||||
async def validate_model_requirement_match(
|
||||
request: ValidateMatchRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""需求-模型匹配校验"""
|
||||
_ = current_user
|
||||
service = _build_service(db)
|
||||
try:
|
||||
data = await service.validate_model_requirement_match(
|
||||
truth_node_id=request.truth_node_id,
|
||||
model_version_id=request.model_version_id,
|
||||
tenant_id=tenant_id,
|
||||
project_id=request.project_id,
|
||||
)
|
||||
return success_response(data=data)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
|
||||
)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"匹配校验失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/capability-gaps")
|
||||
async def list_capability_gaps(
|
||||
status: Optional[str] = Query(default=None),
|
||||
severity: Optional[str] = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""列出能力差距预警"""
|
||||
_ = current_user
|
||||
service = _build_service(db)
|
||||
try:
|
||||
data = await service.list_capability_gaps(
|
||||
tenant_id=tenant_id,
|
||||
status=status,
|
||||
severity=severity,
|
||||
limit=limit,
|
||||
)
|
||||
return success_response(data=data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询能力差距失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cost-tracking/{truth_node_id}")
|
||||
async def get_cost_tracking(
|
||||
truth_node_id: int,
|
||||
period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询成本跟踪与 ROI"""
|
||||
_ = current_user
|
||||
service = _build_service(db)
|
||||
try:
|
||||
data = await service.get_cost_tracking(
|
||||
truth_node_id=truth_node_id,
|
||||
period=period,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
return success_response(data=data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本跟踪失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cost-optimization/{truth_node_id}")
|
||||
async def get_cost_optimization(
|
||||
truth_node_id: int,
|
||||
period: str = Query(default="weekly", pattern="^(daily|weekly|monthly|project)$"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询成本优化建议(必要时自动触发一次计算)"""
|
||||
_ = current_user
|
||||
service = _build_service(db)
|
||||
try:
|
||||
tracking = await service.get_cost_tracking(
|
||||
truth_node_id=truth_node_id,
|
||||
period=period,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
optimization = tracking.get("optimization_suggestions") or {}
|
||||
if not optimization:
|
||||
computed = await service.track_cost_and_roi(
|
||||
truth_node_id=truth_node_id,
|
||||
period=period,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
optimization = computed.get("optimization_suggestions", {})
|
||||
tracking = await service.get_cost_tracking(
|
||||
truth_node_id=truth_node_id,
|
||||
period=period,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"truth_node_id": truth_node_id,
|
||||
"period": period,
|
||||
"optimization_suggestions": optimization,
|
||||
"roi_score": tracking.get("roi_score"),
|
||||
"actual_cost_usd": tracking.get("actual_cost_usd", 0.0),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询成本优化失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/lifecycle/assess-impact")
|
||||
async def assess_model_update_impact(
|
||||
request: LifecycleImpactRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""评估模型升级对现有需求集合的影响。"""
|
||||
_ = current_user
|
||||
service = ModelLifecycleService(db)
|
||||
try:
|
||||
data = await service.assess_model_update_impact(
|
||||
old_model_version_id=request.old_model_version_id,
|
||||
new_model_version_id=request.new_model_version_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
return success_response(data=data)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
|
||||
)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"影响评估失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/lifecycle/impacts")
|
||||
async def list_lifecycle_impacts(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""列出模型生命周期影响评估记录。"""
|
||||
_ = current_user
|
||||
service = ModelLifecycleService(db)
|
||||
try:
|
||||
data = await service.list_impacts(tenant_id=tenant_id, limit=limit)
|
||||
return success_response(data=data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询影响评估失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/lifecycle/{impact_id}/migration-tasks")
|
||||
async def create_migration_tasks(
|
||||
impact_id: int,
|
||||
request: GenerateMigrationTasksRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""基于影响评估自动生成迁移任务建议。"""
|
||||
_ = current_user
|
||||
service = ModelLifecycleService(db)
|
||||
try:
|
||||
data = await service.generate_migration_tasks(
|
||||
impact_id=impact_id,
|
||||
tenant_id=tenant_id,
|
||||
max_tasks=request.max_tasks,
|
||||
)
|
||||
return success_response(data=data)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
|
||||
)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"生成迁移任务失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/experiment-sync")
|
||||
async def create_experiment_sync(
|
||||
request: ExperimentSyncRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建实验结果同步记录,满足阈值时自动同步。"""
|
||||
_ = current_user
|
||||
service = ExperimentSyncService(db)
|
||||
try:
|
||||
data = await service.auto_sync_experiment_result(
|
||||
experiment_id=request.experiment_id,
|
||||
truth_node_id=request.truth_node_id,
|
||||
tenant_id=tenant_id,
|
||||
project_id=request.project_id,
|
||||
sync_type=request.sync_type,
|
||||
auto_apply_threshold=request.auto_apply_threshold,
|
||||
)
|
||||
return success_response(data=data)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error_response(ErrorCode.NOT_FOUND, str(exc)),
|
||||
)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"创建同步失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/experiment-sync")
|
||||
async def list_experiment_sync_records(
|
||||
truth_node_id: Optional[int] = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询实验同步记录。"""
|
||||
_ = current_user
|
||||
service = ExperimentSyncService(db)
|
||||
try:
|
||||
data = await service.list_sync_records(
|
||||
tenant_id=tenant_id,
|
||||
truth_node_id=truth_node_id,
|
||||
limit=limit,
|
||||
)
|
||||
return success_response(data=data)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"查询同步记录失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/experiment-sync/{sync_id}/review")
|
||||
async def review_experiment_sync(
|
||||
sync_id: int,
|
||||
request: ExperimentSyncReviewRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""人工审核实验同步记录。"""
|
||||
service = ExperimentSyncService(db)
|
||||
try:
|
||||
data = await service.review_sync(
|
||||
sync_id=sync_id,
|
||||
tenant_id=tenant_id,
|
||||
reviewer_id=current_user.id,
|
||||
action=request.action,
|
||||
review_notes=request.review_notes,
|
||||
)
|
||||
return success_response(data=data)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
|
||||
)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"审核同步失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/experiment-sync/{sync_id}/rollback")
|
||||
async def rollback_experiment_sync(
|
||||
sync_id: int,
|
||||
request: ExperimentSyncRollbackRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""回滚已应用的实验同步。"""
|
||||
service = ExperimentSyncService(db)
|
||||
try:
|
||||
data = await service.rollback_sync(
|
||||
sync_id=sync_id,
|
||||
tenant_id=tenant_id,
|
||||
reviewer_id=current_user.id,
|
||||
rollback_notes=request.rollback_notes,
|
||||
)
|
||||
return success_response(data=data)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
|
||||
)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"回滚同步失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cost-optimization/analyze")
|
||||
async def analyze_cost_optimization(
|
||||
request: CostOptimizationAnalyzeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""生成并持久化成本优化建议。"""
|
||||
_ = current_user
|
||||
service = CostOptimizationService(db)
|
||||
try:
|
||||
data = await service.analyze_cost_optimization(
|
||||
truth_node_id=request.truth_node_id,
|
||||
tenant_id=tenant_id,
|
||||
period=request.period,
|
||||
project_id=request.project_id,
|
||||
)
|
||||
return success_response(data=data)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=error_response(ErrorCode.BAD_REQUEST, str(exc)),
|
||||
)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, f"成本优化分析失败: {exc}"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/context/projects/{project_id}/truth-nodes")
|
||||
async def list_project_truth_nodes(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取项目上下文中的宪章列表,用于前端选择器。"""
|
||||
_ = current_user
|
||||
result = await db.execute(
|
||||
select(TruthNode).where(
|
||||
and_(
|
||||
TruthNode.tenant_id == tenant_id,
|
||||
TruthNode.project_id == project_id,
|
||||
)
|
||||
).order_by(TruthNode.created_at.desc())
|
||||
)
|
||||
nodes = result.scalars().all()
|
||||
return success_response(
|
||||
data={
|
||||
"items": [
|
||||
{
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"status": n.status,
|
||||
"node_type": n.node_type,
|
||||
}
|
||||
for n in nodes
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/context/projects/{project_id}/experiments")
|
||||
async def list_project_experiments(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。"""
|
||||
_ = current_user
|
||||
truth_ids_result = await db.execute(
|
||||
select(TruthNode.id).where(
|
||||
and_(
|
||||
TruthNode.tenant_id == tenant_id,
|
||||
TruthNode.project_id == project_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
truth_ids = {int(row[0]) for row in truth_ids_result.all()}
|
||||
if not truth_ids:
|
||||
return success_response(data={"items": []})
|
||||
|
||||
exp_result = await db.execute(
|
||||
select(Experiment)
|
||||
.where(Experiment.tenant_id == tenant_id)
|
||||
.order_by(Experiment.created_at.desc())
|
||||
)
|
||||
experiments = exp_result.scalars().all()
|
||||
|
||||
def _linked_ids(raw) -> set[int]:
|
||||
ids = set()
|
||||
if isinstance(raw, list):
|
||||
for x in raw:
|
||||
try:
|
||||
ids.add(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return ids
|
||||
|
||||
items = []
|
||||
for e in experiments:
|
||||
if e.project_id is not None:
|
||||
if int(e.project_id) != int(project_id):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": e.id,
|
||||
"title": e.title or f"Experiment {e.id}",
|
||||
"status": e.status,
|
||||
"target_metric": e.target_metric,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# 迁移兼容:project_id 为空时,回退到 linked_context_nodes 交叉判断。
|
||||
if _linked_ids(e.linked_context_nodes).isdisjoint(truth_ids):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": e.id,
|
||||
"title": e.title or f"Experiment {e.id}",
|
||||
"status": e.status,
|
||||
"target_metric": e.target_metric,
|
||||
}
|
||||
)
|
||||
return success_response(data={"items": items})
|
||||
|
||||
|
|
@ -34,12 +34,13 @@ async def analyze_cost_optimization(
|
|||
@router.get("/context/projects/{project_id}/truth-nodes")
|
||||
async def list_project_truth_nodes(
|
||||
project_id: int,
|
||||
permission_user: User = Depends(require_project_member_or_manager),
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取项目上下文中的宪章列表,用于前端选择器。"""
|
||||
_ = current_user
|
||||
_ = (current_user, permission_user)
|
||||
result = await db.execute(
|
||||
select(TruthNode).where(
|
||||
and_(
|
||||
|
|
@ -66,12 +67,13 @@ async def list_project_truth_nodes(
|
|||
@router.get("/context/projects/{project_id}/experiments")
|
||||
async def list_project_experiments(
|
||||
project_id: int,
|
||||
permission_user: User = Depends(require_project_member_or_manager),
|
||||
current_user: User = Depends(get_current_user),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取项目上下文中的实验列表(基于 linked_context_nodes 关联)。"""
|
||||
_ = current_user
|
||||
_ = (current_user, permission_user)
|
||||
truth_ids_result = await db.execute(
|
||||
select(TruthNode.id).where(
|
||||
and_(
|
||||
|
|
@ -128,4 +130,3 @@ async def list_project_experiments(
|
|||
}
|
||||
)
|
||||
return success_response(data={"items": items})
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from ...core.response import ErrorCode, error_response, success_response
|
||||
from ...core.database import get_db
|
||||
from ...dependencies import get_current_user, get_tenant_id
|
||||
from ...dependencies import get_current_user, get_tenant_id, require_project_member_or_manager
|
||||
from ...models import Experiment, TruthNode, User
|
||||
from ...schemas.model_capability import (
|
||||
CostOptimizationAnalyzeRequest,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from ..core.response import error_response, success_response
|
||||
from ..core.database import get_db
|
||||
from ..dependencies import get_current_user
|
||||
from ..dependencies import require_platform_ai_ops
|
||||
from ..models import User
|
||||
from ..services.auth import has_any_role
|
||||
from ..services.model.model_routing import ModelRoutingService
|
||||
from ..services.model.model_routing import ModelRoutingService, PLATFORM_RUNTIME_TENANT_ID
|
||||
from ..services.operation_log_service import log_operation
|
||||
|
||||
router = APIRouter(prefix="/model-routing", tags=["model_routing"])
|
||||
|
|
@ -27,9 +26,9 @@ class ModelRoutingUpdateRequest(BaseModel):
|
|||
@router.get("/functions")
|
||||
async def list_model_routing(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
tenant_id = PLATFORM_RUNTIME_TENANT_ID
|
||||
items = await ModelRoutingService.list_effective_routing(db, tenant_id=tenant_id)
|
||||
return success_response({"items": items})
|
||||
|
||||
|
|
@ -38,9 +37,9 @@ async def list_model_routing(
|
|||
async def get_model_routing(
|
||||
function_name: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
tenant_id = PLATFORM_RUNTIME_TENANT_ID
|
||||
data = await ModelRoutingService.get_effective_routing(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
|
|
@ -54,12 +53,9 @@ async def update_model_routing(
|
|||
function_name: str,
|
||||
body: ModelRoutingUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
):
|
||||
if not has_any_role(str(current_user.role or ""), ["BOSS", "ADMIN"]):
|
||||
return JSONResponse(status_code=403, content=error_response(403, "仅管理员可修改模型路由配置"))
|
||||
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
tenant_id = PLATFORM_RUNTIME_TENANT_ID
|
||||
before = await ModelRoutingService.get_effective_routing(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ router = APIRouter(prefix="/ai-steward/operation-catalog", tags=["AI Steward"])
|
|||
|
||||
|
||||
def _require_manager(user: User) -> None:
|
||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可查看 Operation Catalog 质量报告")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.database import AsyncSessionLocal, get_db
|
||||
from ...core.response import error_response, success_response
|
||||
from ...dependencies import get_current_user
|
||||
from ...models import User
|
||||
from ...schemas.pmflow_launch import PMFlowLaunchConfirmRequest, PMFlowLaunchDraftJobRequest, PMFlowLaunchDraftRequest
|
||||
from ...services.pmflow_launch_service import PMFlowLaunchError, PMFlowLaunchService
|
||||
|
||||
router = APIRouter(prefix="/pmflow-launch", tags=["pmflow-launch"])
|
||||
|
||||
|
||||
async def _run_launch_draft_job(*, tenant_id: int, job_id: str) -> None:
|
||||
async with AsyncSessionLocal() as bg_db:
|
||||
service = PMFlowLaunchService(bg_db, tenant_id=int(tenant_id))
|
||||
await service.run_draft_job(job_id=job_id)
|
||||
|
||||
|
||||
@router.post("/drafts")
|
||||
async def create_pmflow_launch_draft(
|
||||
body: PMFlowLaunchDraftRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
payload = await service.create_draft(body=body, user_id=int(current_user.id))
|
||||
except PMFlowLaunchError as exc:
|
||||
return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}"))
|
||||
return success_response(payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/draft-jobs")
|
||||
async def create_pmflow_launch_draft_job(
|
||||
body: PMFlowLaunchDraftJobRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
payload = await service.create_draft_job(body=body, user_id=int(current_user.id))
|
||||
except PMFlowLaunchError as exc:
|
||||
return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}"))
|
||||
asyncio.create_task(
|
||||
_run_launch_draft_job(tenant_id=int(current_user.tenant_id), job_id=payload.job_id),
|
||||
name=f"pmflow-launch-draft-job-{payload.job_id}",
|
||||
)
|
||||
return success_response(payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/draft-jobs/{job_id}")
|
||||
async def get_pmflow_launch_draft_job(
|
||||
job_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
payload = await service.get_draft_job(job_id=job_id, user_id=int(current_user.id))
|
||||
except PMFlowLaunchError as exc:
|
||||
return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}"))
|
||||
return success_response(payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/draft-jobs/{job_id}/cancel")
|
||||
async def cancel_pmflow_launch_draft_job(
|
||||
job_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
payload = await service.cancel_draft_job(job_id=job_id, user_id=int(current_user.id))
|
||||
except PMFlowLaunchError as exc:
|
||||
return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}"))
|
||||
return success_response(payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/drafts/{draft_id}/revise")
|
||||
async def revise_pmflow_launch_draft(
|
||||
draft_id: str,
|
||||
body: PMFlowLaunchDraftJobRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
mode = body.mode if body.mode in {"merge_supplement", "regenerate"} else "merge_supplement"
|
||||
normalized_body = body.model_copy(update={"parent_draft_id": draft_id, "mode": mode})
|
||||
service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
payload = await service.create_draft_job(body=normalized_body, user_id=int(current_user.id))
|
||||
except PMFlowLaunchError as exc:
|
||||
return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}"))
|
||||
asyncio.create_task(
|
||||
_run_launch_draft_job(tenant_id=int(current_user.tenant_id), job_id=payload.job_id),
|
||||
name=f"pmflow-launch-draft-job-{payload.job_id}",
|
||||
)
|
||||
return success_response(payload.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/drafts/{draft_id}/confirm")
|
||||
async def confirm_pmflow_launch_draft(
|
||||
draft_id: str,
|
||||
body: PMFlowLaunchConfirmRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if not body.confirm:
|
||||
return JSONResponse(status_code=400, content=error_response(400, "confirm_required: confirm must be true"))
|
||||
service = PMFlowLaunchService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
payload = await service.confirm_draft(
|
||||
draft_id=draft_id,
|
||||
edited_draft=body.edited_draft,
|
||||
user_id=int(current_user.id),
|
||||
)
|
||||
except PMFlowLaunchError as exc:
|
||||
return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}"))
|
||||
return success_response(payload.model_dump(mode="json"))
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,386 @@
|
|||
"""项目管理 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..dependencies import get_current_user, get_tenant_id, require_roles
|
||||
from ..models import Project, ProjectSignalLink, Signal, Task, User
|
||||
from ..services.context_cache import context_cache_service
|
||||
from ..utils.project_members import ensure_project_default_members
|
||||
from ..utils.sequence import ensure_project_id_sequence
|
||||
from ..utils.title import compact_title
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
DONE_STATUSES = {"done", "completed", "closed", "resolved", "archived"}
|
||||
|
||||
|
||||
def _as_dict(value: object) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {"text": value}
|
||||
except (TypeError, ValueError):
|
||||
return {"text": value}
|
||||
return {}
|
||||
|
||||
|
||||
class ProjectCreateRequest(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: str = "active"
|
||||
domain_id: Optional[int] = None
|
||||
assigned_role: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectUpdateRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
domain_id: Optional[int] = None
|
||||
assigned_role: Optional[str] = None
|
||||
|
||||
|
||||
class LinkSignalRequest(BaseModel):
|
||||
link_type: Optional[str] = "related"
|
||||
relevance_score: Optional[float] = 1.0
|
||||
|
||||
|
||||
def _serialize_project_row(project: Project) -> dict:
|
||||
return {
|
||||
"id": str(project.id),
|
||||
"title": project.title or project.name,
|
||||
"description": project.description,
|
||||
"status": project.status,
|
||||
"domain_id": str(project.domain_id) if project.domain_id is not None else None,
|
||||
"assigned_role": project.assigned_role,
|
||||
"signal_count": int(project.signal_count or 0),
|
||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||
"updated_at": project.updated_at.isoformat() if project.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _build_project_summary(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project: Project,
|
||||
) -> dict:
|
||||
base = _serialize_project_row(project)
|
||||
|
||||
signal_count_result = await db.execute(
|
||||
select(func.count(ProjectSignalLink.id)).where(
|
||||
and_(
|
||||
ProjectSignalLink.tenant_id == tenant_id,
|
||||
ProjectSignalLink.project_id == int(project.id),
|
||||
)
|
||||
)
|
||||
)
|
||||
signal_count = int(signal_count_result.scalar() or 0)
|
||||
base["signal_count"] = signal_count
|
||||
|
||||
task_rows = (
|
||||
await db.execute(
|
||||
select(Task).where(
|
||||
and_(
|
||||
Task.tenant_id == tenant_id,
|
||||
Task.project_id == int(project.id),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
total_tasks = len(task_rows)
|
||||
completed_tasks = 0
|
||||
in_progress_tasks = 0
|
||||
pending_tasks = 0
|
||||
task_items: list[dict] = []
|
||||
for task in task_rows:
|
||||
status_value = (task.status or "").lower()
|
||||
if status_value in DONE_STATUSES:
|
||||
completed_tasks += 1
|
||||
elif status_value in {"in_progress", "review", "blocked"}:
|
||||
in_progress_tasks += 1
|
||||
else:
|
||||
pending_tasks += 1
|
||||
|
||||
task_items.append(
|
||||
{
|
||||
"task_name": task.title,
|
||||
"type": "task",
|
||||
"status": task.status,
|
||||
"signal_id": str(task.truth_node_id) if task.truth_node_id is not None else "",
|
||||
}
|
||||
)
|
||||
|
||||
base["progress"] = round((completed_tasks / total_tasks) * 100, 2) if total_tasks else 0.0
|
||||
base["total_tasks"] = total_tasks
|
||||
base["completed_tasks"] = completed_tasks
|
||||
base["in_progress_tasks"] = in_progress_tasks
|
||||
base["pending_tasks"] = pending_tasks
|
||||
base["tasks"] = task_items
|
||||
return base
|
||||
|
||||
|
||||
async def _get_project_or_404(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tenant_id: int,
|
||||
project_id: int,
|
||||
) -> Project:
|
||||
project = (
|
||||
await db.execute(
|
||||
select(Project).where(
|
||||
and_(
|
||||
Project.tenant_id == tenant_id,
|
||||
Project.id == project_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_projects(
|
||||
summary: Optional[int] = Query(default=0),
|
||||
status: Optional[str] = Query(default=None),
|
||||
assigned_role: Optional[str] = Query(default=None),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Project).where(Project.tenant_id == tenant_id)
|
||||
if status:
|
||||
query = query.where(Project.status == status)
|
||||
if assigned_role:
|
||||
query = query.where(Project.assigned_role == assigned_role)
|
||||
query = query.order_by(Project.updated_at.desc(), Project.id.desc())
|
||||
projects = (await db.execute(query)).scalars().all()
|
||||
|
||||
if int(summary or 0) == 1:
|
||||
rows = [
|
||||
await _build_project_summary(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
project=project,
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
return {"projects": rows}
|
||||
|
||||
return [_serialize_project_row(project) for project in projects]
|
||||
|
||||
|
||||
@router.get("/{project_id}")
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
return _serialize_project_row(project)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_project(
|
||||
body: ProjectCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
title = compact_title(body.title, max_len=24)
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title required")
|
||||
await ensure_project_id_sequence(db)
|
||||
project = Project(
|
||||
tenant_id=tenant_id,
|
||||
name=title,
|
||||
title=title,
|
||||
description=body.description,
|
||||
status=body.status or "active",
|
||||
domain_id=body.domain_id,
|
||||
assigned_role=body.assigned_role,
|
||||
owner_id=int(current_user.id),
|
||||
signal_count=0,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
await ensure_project_default_members(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=int(project.id),
|
||||
owner_id=int(current_user.id),
|
||||
assigned_role=project.assigned_role,
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
return _serialize_project_row(project)
|
||||
|
||||
|
||||
@router.patch("/{project_id}")
|
||||
async def update_project(
|
||||
project_id: int,
|
||||
body: ProjectUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
if body.title is not None:
|
||||
title = compact_title(body.title, max_len=24)
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title cannot be empty")
|
||||
project.title = title
|
||||
project.name = title
|
||||
if body.description is not None:
|
||||
project.description = body.description
|
||||
if body.status is not None:
|
||||
project.status = body.status
|
||||
if body.domain_id is not None:
|
||||
project.domain_id = body.domain_id
|
||||
if body.assigned_role is not None:
|
||||
project.assigned_role = body.assigned_role
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
return _serialize_project_row(project)
|
||||
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
_ = current_user
|
||||
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
await db.execute(
|
||||
delete(ProjectSignalLink).where(
|
||||
and_(
|
||||
ProjectSignalLink.tenant_id == tenant_id,
|
||||
ProjectSignalLink.project_id == project_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(Task).where(
|
||||
and_(
|
||||
Task.tenant_id == tenant_id,
|
||||
Task.project_id == project_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(Project).where(
|
||||
and_(
|
||||
Project.tenant_id == tenant_id,
|
||||
Project.id == project_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
return {"message": "Project deleted"}
|
||||
|
||||
|
||||
@router.post("/{project_id}/signals/{signal_id}")
|
||||
async def link_signal_to_project(
|
||||
project_id: int,
|
||||
signal_id: int,
|
||||
body: LinkSignalRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
|
||||
signal = (
|
||||
await db.execute(
|
||||
select(Signal).where(
|
||||
and_(
|
||||
Signal.tenant_id == tenant_id,
|
||||
Signal.id == signal_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not signal:
|
||||
raise HTTPException(status_code=404, detail="Signal not found")
|
||||
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(ProjectSignalLink).where(
|
||||
and_(
|
||||
ProjectSignalLink.tenant_id == tenant_id,
|
||||
ProjectSignalLink.project_id == project_id,
|
||||
ProjectSignalLink.signal_id == signal_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return {
|
||||
"id": str(existing.id),
|
||||
"project_id": str(existing.project_id),
|
||||
"signal_id": str(existing.signal_id),
|
||||
"link_type": existing.link_type,
|
||||
"relevance_score": float(existing.relevance_score or 0.0),
|
||||
"created_at": existing.created_at.isoformat() if existing.created_at else None,
|
||||
}
|
||||
|
||||
link = ProjectSignalLink(
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
signal_id=signal_id,
|
||||
link_type=body.link_type or "related",
|
||||
relevance_score=float(body.relevance_score or 1.0),
|
||||
)
|
||||
db.add(link)
|
||||
await db.commit()
|
||||
await db.refresh(link)
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
return {
|
||||
"id": str(link.id),
|
||||
"project_id": str(link.project_id),
|
||||
"signal_id": str(link.signal_id),
|
||||
"link_type": link.link_type,
|
||||
"relevance_score": float(link.relevance_score or 0.0),
|
||||
"created_at": link.created_at.isoformat() if link.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{project_id}/signals/{signal_id}")
|
||||
async def unlink_signal_from_project(
|
||||
project_id: int,
|
||||
signal_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
_ = current_user
|
||||
await db.execute(
|
||||
delete(ProjectSignalLink).where(
|
||||
and_(
|
||||
ProjectSignalLink.tenant_id == tenant_id,
|
||||
ProjectSignalLink.project_id == project_id,
|
||||
ProjectSignalLink.signal_id == signal_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
return {"message": "Signal unlinked"}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from ...services.auth import has_any_role
|
||||
|
||||
def _as_dict(value: object) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
|
|
@ -123,6 +124,46 @@ async def _get_project_or_404(
|
|||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
def _is_project_manager(user: User) -> bool:
|
||||
return has_any_role(str(getattr(user, "role", "") or ""), ["OWNER", "REVIEWER"])
|
||||
|
||||
|
||||
def _visible_project_filter(*, current_user: User):
|
||||
if _is_project_manager(current_user):
|
||||
return True
|
||||
return or_(
|
||||
Project.owner_id == int(current_user.id),
|
||||
Project.id.in_(
|
||||
select(UserProject.project_id).where(
|
||||
and_(
|
||||
UserProject.tenant_id == int(current_user.tenant_id),
|
||||
UserProject.user_id == int(current_user.id),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _get_visible_project_or_404(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
project_id: int,
|
||||
) -> Project:
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
query = select(Project).where(
|
||||
and_(
|
||||
Project.tenant_id == tenant_id,
|
||||
Project.id == int(project_id),
|
||||
_visible_project_filter(current_user=current_user),
|
||||
)
|
||||
)
|
||||
project = (await db.execute(query)).scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return project
|
||||
|
||||
async def _cleanup_project_dependencies(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from .helpers import _build_project_summary, _get_project_or_404
|
||||
from .helpers import _build_project_summary, _get_visible_project_or_404, _serialize_project_row, _visible_project_filter
|
||||
|
||||
@router.get("/")
|
||||
async def list_projects(
|
||||
summary: Optional[int] = Query(default=0),
|
||||
status: Optional[str] = Query(default=None),
|
||||
assigned_role: Optional[str] = Query(default=None),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
query = select(Project).where(Project.tenant_id == tenant_id)
|
||||
query = query.where(_visible_project_filter(current_user=current_user))
|
||||
if status:
|
||||
query = query.where(Project.status == status)
|
||||
if assigned_role:
|
||||
|
|
@ -35,9 +37,8 @@ async def list_projects(
|
|||
@router.get("/{project_id}")
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
project = await _get_visible_project_or_404(db, current_user=current_user, project_id=project_id)
|
||||
return _serialize_project_row(project)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,37 @@ from .shared import *
|
|||
from .helpers import _cleanup_project_dependencies, _fetch_project_member_user_ids, _get_project_or_404, _serialize_project_row
|
||||
from .schemas import ProjectCreateRequest, ProjectMembersAssignRequest, ProjectUpdateRequest
|
||||
|
||||
|
||||
async def _find_existing_project_by_title(db: AsyncSession, *, tenant_id: int, title: str) -> Project | None:
|
||||
title_expr = func.coalesce(func.nullif(Project.title, ""), func.nullif(Project.name, ""))
|
||||
result = await db.execute(
|
||||
select(Project)
|
||||
.where(
|
||||
and_(
|
||||
Project.tenant_id == tenant_id,
|
||||
Project.status != "inactive",
|
||||
title_expr.is_not(None),
|
||||
func.lower(title_expr) == title.lower(),
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_project(
|
||||
body: ProjectCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
title = compact_title(body.title, max_len=24)
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title required")
|
||||
existing_project = await _find_existing_project_by_title(db, tenant_id=tenant_id, title=title)
|
||||
if existing_project is not None:
|
||||
raise HTTPException(status_code=409, detail="project title already exists")
|
||||
try:
|
||||
member_user_ids = await resolve_project_member_ids(
|
||||
db,
|
||||
|
|
@ -88,7 +109,7 @@ async def assign_project_members(
|
|||
project_id: int,
|
||||
body: ProjectMembersAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
|
|
@ -157,7 +178,7 @@ async def update_project(
|
|||
project_id: int,
|
||||
body: ProjectUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
project = await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
|
|
@ -185,7 +206,7 @@ async def update_project(
|
|||
async def delete_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
_ = current_user
|
||||
|
|
@ -214,4 +235,3 @@ async def delete_project(
|
|||
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
|
||||
await AIPMOKernelService.invalidate_world_model_cache(tenant_id=tenant_id, project_id=project_id)
|
||||
return {"message": "Project deleted"}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ async def link_signal_to_project(
|
|||
signal_id: int,
|
||||
body: LinkSignalRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
await _get_project_or_404(db, tenant_id=tenant_id, project_id=project_id)
|
||||
|
|
@ -74,7 +74,7 @@ async def unlink_signal_from_project(
|
|||
project_id: int,
|
||||
signal_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
):
|
||||
tenant_id = int(current_user.tenant_id)
|
||||
_ = current_user
|
||||
|
|
|
|||
|
|
@ -23,4 +23,3 @@ class LinkSignalRequest(BaseModel):
|
|||
|
||||
class ProjectMembersAssignRequest(BaseModel):
|
||||
member_user_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from typing import Any, Optional
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, delete, func, select, update
|
||||
from sqlalchemy import and_, delete, func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,372 @@
|
|||
"""Platform Prompt asset inventory API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.response import error_response, success_response
|
||||
from ..dependencies.permissions import require_platform_ai_ops
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import User
|
||||
from ..schemas.prompt_assets import (
|
||||
PromptAssetReleaseActionRequest,
|
||||
PromptAssetReleaseCheckRunRequest,
|
||||
PromptAssetReleaseCreateRequest,
|
||||
PromptGoldenCaseCreateRequest,
|
||||
PromptGoldenCaseFromCallRequest,
|
||||
PromptReplayRunCreateRequest,
|
||||
)
|
||||
from ..services.prompt_assets_service import PromptAssetService
|
||||
|
||||
router = APIRouter(prefix="/prompt-assets", tags=["prompt_assets"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_prompt_assets(
|
||||
family: str | None = Query(default=None),
|
||||
runtime_source: str | None = Query(default=None),
|
||||
editable_policy: str | None = Query(default=None),
|
||||
keyword: str | None = Query(default=None, max_length=200),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
data = await PromptAssetService(db).list_assets(
|
||||
tenant_id=effective_tenant_id,
|
||||
family=family,
|
||||
runtime_source=runtime_source,
|
||||
editable_policy=editable_policy,
|
||||
keyword=keyword,
|
||||
)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/{prompt_key}")
|
||||
async def get_prompt_asset(
|
||||
prompt_key: str,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).get_asset(tenant_id=effective_tenant_id, prompt_key=prompt_key)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/{prompt_key}/quality-summary")
|
||||
async def get_prompt_asset_quality_summary(
|
||||
prompt_key: str,
|
||||
window: str = Query(default="7d", pattern="^(24h|7d|30d)$"),
|
||||
status: str | None = Query(default=None, max_length=80),
|
||||
release_id: int | None = Query(default=None),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).get_quality_summary(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
window=window,
|
||||
status=status,
|
||||
release_id=release_id,
|
||||
include_all_pmflow_feedback=True,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/{prompt_key}/golden-cases")
|
||||
async def list_prompt_asset_golden_cases(
|
||||
prompt_key: str,
|
||||
status: str | None = Query(default=None, max_length=40),
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).list_golden_cases(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
status=status,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/{prompt_key}/golden-cases")
|
||||
async def create_prompt_asset_golden_case(
|
||||
prompt_key: str,
|
||||
request: PromptGoldenCaseCreateRequest,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).create_golden_case(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
case_name=request.case_name,
|
||||
input_payload=request.input_payload,
|
||||
expected_contract=request.expected_contract,
|
||||
expected_contains=request.expected_contains,
|
||||
case_type=request.case_type,
|
||||
priority=request.priority,
|
||||
scenario_key=request.scenario_key,
|
||||
tags=request.tags,
|
||||
created_by=int(current_user.id) if getattr(current_user, "id", None) is not None else None,
|
||||
notes=request.notes,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/{prompt_key}/golden-cases/from-call/{ai_call_log_id}")
|
||||
async def create_prompt_asset_golden_case_from_call(
|
||||
prompt_key: str,
|
||||
ai_call_log_id: int,
|
||||
request: PromptGoldenCaseFromCallRequest | None = None,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).create_golden_case_from_call(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
ai_call_log_id=ai_call_log_id,
|
||||
created_by=int(current_user.id) if getattr(current_user, "id", None) is not None else None,
|
||||
case_name=request.case_name if request else None,
|
||||
notes=request.notes if request else None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/{prompt_key}/releases")
|
||||
async def list_prompt_asset_releases(
|
||||
prompt_key: str,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).list_releases(tenant_id=effective_tenant_id, prompt_key=prompt_key)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/{prompt_key}/releases")
|
||||
async def create_prompt_asset_release(
|
||||
prompt_key: str,
|
||||
request: PromptAssetReleaseCreateRequest,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).create_release(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
body_snapshot=request.body_snapshot,
|
||||
created_by=int(current_user.id) if getattr(current_user, "id", None) is not None else None,
|
||||
notes=request.notes,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/{prompt_key}/releases/{release_id}/activate")
|
||||
async def activate_prompt_asset_release(
|
||||
prompt_key: str,
|
||||
release_id: int,
|
||||
request: PromptAssetReleaseActionRequest | None = None,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = request
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).activate_release(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
release_id=release_id,
|
||||
actor_id=int(current_user.id) if getattr(current_user, "id", None) is not None else None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/{prompt_key}/releases/{release_id}/checks")
|
||||
async def list_prompt_asset_release_checks(
|
||||
prompt_key: str,
|
||||
release_id: int,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).list_release_checks(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
release_id=release_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/{prompt_key}/releases/{release_id}/checks/run")
|
||||
async def run_prompt_asset_release_checks(
|
||||
prompt_key: str,
|
||||
release_id: int,
|
||||
request: PromptAssetReleaseCheckRunRequest | None = None,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).run_release_checks(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
release_id=release_id,
|
||||
actor_id=int(current_user.id) if getattr(current_user, "id", None) is not None else None,
|
||||
reason=request.reason if request else None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/{prompt_key}/releases/{release_id}/replay-runs")
|
||||
async def create_prompt_asset_replay_run(
|
||||
prompt_key: str,
|
||||
release_id: int,
|
||||
request: PromptReplayRunCreateRequest,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).create_replay_run(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
release_id=release_id,
|
||||
created_by=int(current_user.id) if getattr(current_user, "id", None) is not None else None,
|
||||
mode=request.mode,
|
||||
case_status=request.case_status,
|
||||
max_cases=request.max_cases,
|
||||
include_llm=request.include_llm,
|
||||
judge_enabled=request.judge_enabled,
|
||||
reason=request.reason,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/{prompt_key}/releases/{release_id}/watch-summary")
|
||||
async def get_prompt_asset_release_watch_summary(
|
||||
prompt_key: str,
|
||||
release_id: int,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).get_release_watch_summary(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
release_id=release_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/{prompt_key}/releases/{release_id}/replay-runs")
|
||||
async def list_prompt_asset_replay_runs(
|
||||
prompt_key: str,
|
||||
release_id: int,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).list_replay_runs(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
release_id=release_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/{prompt_key}/replay-runs/{run_id}")
|
||||
async def get_prompt_asset_replay_run(
|
||||
prompt_key: str,
|
||||
run_id: int,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).get_replay_run(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
run_id=run_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=404, content=error_response(404, str(exc)))
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/{prompt_key}/releases/{release_id}/rollback")
|
||||
async def rollback_prompt_asset_release(
|
||||
prompt_key: str,
|
||||
release_id: int,
|
||||
request: PromptAssetReleaseActionRequest | None = None,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = request
|
||||
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
|
||||
try:
|
||||
data = await PromptAssetService(db).rollback_release(
|
||||
tenant_id=effective_tenant_id,
|
||||
prompt_key=prompt_key,
|
||||
release_id=release_id,
|
||||
actor_id=int(current_user.id) if getattr(current_user, "id", None) is not None else None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content=error_response(400, str(exc)))
|
||||
return success_response(data)
|
||||
|
|
@ -33,7 +33,7 @@ class PromptProfileUpdateRequest(BaseModel):
|
|||
|
||||
|
||||
def _require_manager(user: User) -> None:
|
||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||
if not has_any_role(str(user.role or ""), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可修改 Prompt 配置")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,570 @@
|
|||
"""Adaptive RAG API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..core.response import success_response
|
||||
from ..database import get_db
|
||||
from ..dependencies.auth import get_current_user, require_roles
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import User
|
||||
from ..schemas.rag import (
|
||||
EntityResolutionRequest,
|
||||
KnowledgeDocCreateRequest,
|
||||
KnowledgeURLCreateRequest,
|
||||
MultiStepSearchRequest,
|
||||
QAExportQuery,
|
||||
QAFeedbackCreateRequest,
|
||||
QAListQuery,
|
||||
QASessionListQuery,
|
||||
QASessionRenameRequest,
|
||||
RAGAskRequest,
|
||||
SymbolSearchRequest,
|
||||
SymbolSyncRequest,
|
||||
)
|
||||
from ..services.adaptive_rag_service import AdaptiveRAGService
|
||||
from ..services.symbol_index_service import SymbolIndexService
|
||||
|
||||
router = APIRouter(prefix="/rag", tags=["Adaptive RAG"])
|
||||
|
||||
|
||||
def _resolve_tenant_id(user: User, fallback_tenant_id: int) -> int:
|
||||
try:
|
||||
return int(getattr(user, "tenant_id") or fallback_tenant_id or 1)
|
||||
except Exception:
|
||||
return int(fallback_tenant_id or 1)
|
||||
|
||||
|
||||
def _safe_decode_bytes(raw: bytes) -> str:
|
||||
for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"):
|
||||
try:
|
||||
return raw.decode(encoding)
|
||||
except Exception:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
def _extract_text_from_upload(filename: str, raw: bytes) -> tuple[str, str]:
|
||||
name = str(filename or "uploaded.txt")
|
||||
suffix = name.rsplit(".", 1)[-1].lower() if "." in name else "txt"
|
||||
|
||||
if suffix in {"txt", "md", "markdown", "csv", "json", "py", "ts", "tsx", "js", "jsx"}:
|
||||
return ("md" if suffix in {"md", "markdown"} else "txt", _safe_decode_bytes(raw))
|
||||
|
||||
if suffix == "pdf":
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"PDF 解析依赖缺失: {exc}") from exc
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
parts: list[str] = []
|
||||
for idx, page in enumerate(reader.pages, start=1):
|
||||
page_text = (page.extract_text() or "").strip()
|
||||
if not page_text:
|
||||
continue
|
||||
# 写入页码标记,向量分块时会保留 page_number 元数据,便于引用来源页码。
|
||||
parts.append(f"[[PAGE:{idx}]]\n{page_text}")
|
||||
text = "\n\n".join(parts)
|
||||
return ("pdf", text)
|
||||
|
||||
if suffix in {"docx", "doc"}:
|
||||
try:
|
||||
from docx import Document # type: ignore
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Word 解析依赖缺失: {exc}") from exc
|
||||
doc = Document(io.BytesIO(raw))
|
||||
text = "\n".join([p.text for p in doc.paragraphs if p.text])
|
||||
return ("word", text)
|
||||
|
||||
raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{suffix}")
|
||||
|
||||
|
||||
@router.post("/ask")
|
||||
async def rag_ask(
|
||||
request: RAGAskRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
result = await service.answer_question(
|
||||
question=request.question,
|
||||
asked_by=int(current_user.id),
|
||||
asked_via=request.asked_via,
|
||||
project_id=request.project_id,
|
||||
source_chat_id=request.source_chat_id,
|
||||
session_id=request.session_id,
|
||||
force_new_session=bool(request.new_session),
|
||||
thinking_mode=request.thinking_mode,
|
||||
action_mode=request.action_mode,
|
||||
sync_requirement=bool(request.sync_requirement),
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.get("/manifest")
|
||||
async def rag_manifest(
|
||||
project_id: int | None = Query(default=None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
data = await service.get_project_manifest(project_id=project_id)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/features/catalog")
|
||||
async def rag_feature_catalog(
|
||||
query: str | None = Query(default=None),
|
||||
limit: int = Query(default=20, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
data = await service.get_feature_catalog(query=query, limit=limit)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/entity-resolution")
|
||||
async def rag_entity_resolution(
|
||||
request: EntityResolutionRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
data = await service.entity_resolution(
|
||||
question=request.question,
|
||||
project_id=request.project_id,
|
||||
limit=request.limit,
|
||||
)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/multi-step-search")
|
||||
async def rag_multi_step_search(
|
||||
request: MultiStepSearchRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
data = await service.multi_step_search(
|
||||
question=request.question,
|
||||
project_id=request.project_id,
|
||||
source_chat_id=request.source_chat_id,
|
||||
session_state=None,
|
||||
)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/symbols/sync")
|
||||
async def rag_symbols_sync(
|
||||
request: SymbolSyncRequest,
|
||||
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
data = await service.sync_codebase_symbols(
|
||||
base_dir=request.base_dir,
|
||||
max_files=request.max_files,
|
||||
force_reindex=request.force_reindex,
|
||||
)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.post("/symbols/search")
|
||||
async def rag_symbols_search(
|
||||
request: SymbolSearchRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
data = await service.search_symbols(
|
||||
query=request.query,
|
||||
language=request.language,
|
||||
symbol_type=request.symbol_type,
|
||||
limit=request.limit,
|
||||
)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/symbols/overview")
|
||||
async def rag_symbols_overview(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
data = await service.get_overview(top_n_files=10, auto_sync_if_empty=False)
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/symbols/{symbol_id}")
|
||||
async def rag_symbol_detail(
|
||||
symbol_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = SymbolIndexService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
try:
|
||||
data = await service.get_symbol_detail(symbol_id=symbol_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@router.get("/knowledge-base/docs")
|
||||
async def list_knowledge_docs(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
return success_response(await service.list_knowledge_docs())
|
||||
|
||||
|
||||
@router.post("/knowledge-base/docs")
|
||||
async def create_knowledge_doc(
|
||||
request: KnowledgeDocCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
result = await service.create_knowledge_doc(
|
||||
title=request.title,
|
||||
file_type=request.file_type,
|
||||
raw_content=request.content,
|
||||
uploaded_by=int(current_user.id),
|
||||
source_type=request.source_type,
|
||||
file_url=request.file_url,
|
||||
project_id=request.project_id,
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.post("/knowledge-base/upload")
|
||||
async def upload_knowledge_doc(
|
||||
file: UploadFile = File(...),
|
||||
project_id: int | None = Form(default=None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="上传文件为空")
|
||||
if len(raw) > 15 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="文件过大,限制 15MB")
|
||||
|
||||
file_type, text = _extract_text_from_upload(file.filename or "uploaded.txt", raw)
|
||||
if not str(text or "").strip():
|
||||
raise HTTPException(status_code=400, detail="无法提取有效文本内容")
|
||||
|
||||
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
|
||||
doc = await service.create_knowledge_doc_placeholder(
|
||||
title=(file.filename or "uploaded.txt")[:255],
|
||||
file_type=file_type,
|
||||
uploaded_by=int(current_user.id),
|
||||
source_type="file",
|
||||
file_url=None,
|
||||
)
|
||||
asyncio.create_task(
|
||||
AdaptiveRAGService.process_knowledge_doc_content_async(
|
||||
tenant_id=effective_tenant_id,
|
||||
doc_id=int(doc.id),
|
||||
raw_content=text,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
return success_response(service._serialize_doc(doc))
|
||||
|
||||
|
||||
@router.post("/knowledge-base/urls")
|
||||
async def create_knowledge_url(
|
||||
request: KnowledgeURLCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
url = str(request.url or "").strip()
|
||||
if not re.match(r"^https?://", url, flags=re.IGNORECASE):
|
||||
raise HTTPException(status_code=400, detail="URL 必须以 http:// 或 https:// 开头")
|
||||
|
||||
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
|
||||
doc = await service.create_knowledge_doc_placeholder(
|
||||
title=(request.title or url)[:255],
|
||||
file_type="url",
|
||||
uploaded_by=int(current_user.id),
|
||||
source_type="url",
|
||||
file_url=url,
|
||||
)
|
||||
asyncio.create_task(
|
||||
AdaptiveRAGService.refresh_url_doc_async(
|
||||
tenant_id=effective_tenant_id,
|
||||
doc_id=int(doc.id),
|
||||
project_id=request.project_id,
|
||||
)
|
||||
)
|
||||
return success_response(service._serialize_doc(doc))
|
||||
|
||||
|
||||
@router.post("/knowledge-base/docs/{doc_id}/refresh")
|
||||
async def refresh_knowledge_doc(
|
||||
doc_id: int,
|
||||
project_id: int | None = Query(default=None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
effective_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
service = AdaptiveRAGService(db, tenant_id=effective_tenant_id)
|
||||
try:
|
||||
doc = await service.get_knowledge_doc(doc_id=doc_id)
|
||||
if str(doc.get("source_type") or "").lower() != "url":
|
||||
raise HTTPException(status_code=400, detail="仅 URL 类型条目支持 refresh")
|
||||
marked = await service.mark_doc_processing(doc_id=doc_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
asyncio.create_task(
|
||||
AdaptiveRAGService.refresh_url_doc_async(
|
||||
tenant_id=effective_tenant_id,
|
||||
doc_id=doc_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
return success_response(marked)
|
||||
|
||||
|
||||
@router.delete("/knowledge-base/docs/{doc_id}")
|
||||
async def delete_knowledge_doc(
|
||||
doc_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
ok = await service.delete_knowledge_doc(doc_id=doc_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="文档不存在")
|
||||
return success_response({"deleted": True, "doc_id": doc_id})
|
||||
|
||||
|
||||
@router.post("/qa-records/{qa_id}/feedback")
|
||||
async def submit_qa_feedback(
|
||||
qa_id: int,
|
||||
request: QAFeedbackCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
try:
|
||||
result = await service.submit_feedback(
|
||||
qa_id=qa_id,
|
||||
rating=request.rating,
|
||||
helpfulness=request.helpfulness,
|
||||
error_types=request.error_types,
|
||||
correct_answer=request.correct_answer,
|
||||
submitted_by=int(current_user.id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.get("/qa-records")
|
||||
async def list_qa_records(
|
||||
project_id: int | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
rating: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
query = QAListQuery(
|
||||
project_id=project_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
rating=rating,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
result = await service.list_qa_records(
|
||||
project_id=query.project_id,
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
rating=query.rating,
|
||||
page=query.page,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.get("/qa-sessions")
|
||||
async def list_qa_sessions(
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
query = QASessionListQuery(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
result = await service.list_qa_sessions(
|
||||
user_id=int(current_user.id),
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
page=query.page,
|
||||
page_size=query.page_size,
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.get("/qa-sessions/{session_id}")
|
||||
async def qa_session_detail(
|
||||
session_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
try:
|
||||
result = await service.get_qa_session_detail(session_id=session_id, user_id=int(current_user.id))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.get("/qa-sessions/{session_id}/state")
|
||||
async def qa_session_state_replay(
|
||||
session_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
try:
|
||||
result = await service.get_session_state_replay(
|
||||
session_id=session_id,
|
||||
user_id=int(current_user.id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.patch("/qa-sessions/{session_id}/title")
|
||||
async def rename_qa_session(
|
||||
session_id: int,
|
||||
request: QASessionRenameRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
try:
|
||||
result = await service.rename_qa_session(
|
||||
session_id=session_id,
|
||||
user_id=int(current_user.id),
|
||||
title=request.title,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.delete("/qa-sessions/{session_id}")
|
||||
async def delete_qa_session(
|
||||
session_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
deleted = await service.delete_qa_session(session_id=session_id, user_id=int(current_user.id))
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="qa session 不存在")
|
||||
return success_response({"deleted": True, "session_id": session_id})
|
||||
|
||||
|
||||
@router.get("/qa-records/stats")
|
||||
async def qa_records_stats(
|
||||
project_id: int | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
result = await service.get_qa_stats(
|
||||
project_id=project_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.get("/qa-records/export")
|
||||
async def export_qa_records_jsonl(
|
||||
include_unrated: bool = Query(default=False),
|
||||
project_id: int | None = Query(default=None),
|
||||
rating: str | None = Query(default=None),
|
||||
domain: str | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_ = current_user
|
||||
query = QAExportQuery(
|
||||
include_unrated=include_unrated,
|
||||
project_id=project_id,
|
||||
rating=rating,
|
||||
domain=domain,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id))
|
||||
payload = await service.export_qa_jsonl(
|
||||
include_unrated=query.include_unrated,
|
||||
project_id=query.project_id,
|
||||
rating=query.rating,
|
||||
domain=query.domain,
|
||||
date_from=query.date_from,
|
||||
date_to=query.date_to,
|
||||
)
|
||||
return Response(content=payload, media_type="application/x-ndjson")
|
||||
|
|
@ -207,7 +207,7 @@ async def export_qa_records_jsonl(
|
|||
domain: str | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
|
||||
current_user: User = Depends(require_roles("REVIEWER", "OWNER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from .helpers import _resolve_tenant_id
|
|||
from .. import rag_ask_contract
|
||||
from ...core.response import ErrorCode, error_response
|
||||
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
|
||||
from ...services.customer_answer_presentation_service import CustomerAnswerPresentationService
|
||||
|
||||
@router.post("/ask")
|
||||
async def rag_ask(
|
||||
|
|
@ -19,18 +20,36 @@ async def rag_ask(
|
|||
db,
|
||||
tenant_id=rag_ask_contract.resolve_effective_tenant_id(current_user, tenant_id),
|
||||
)
|
||||
ingress_result = await service.process_ingress(
|
||||
**rag_ask_contract.build_kernel_ingress_kwargs(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
try:
|
||||
ingress_result = await service.process_ingress(
|
||||
**rag_ask_contract.build_kernel_ingress_kwargs(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=error_response(ErrorCode.INTERNAL_ERROR, "PMFlow AI 暂时没有生成可展示回答,请稍后重试。"),
|
||||
)
|
||||
)
|
||||
if not bool(ingress_result.get("handled")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=str(ingress_result.get("reason") or "ai_pmo_kernel_not_handled"),
|
||||
)
|
||||
payload = rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result)
|
||||
presented_payload = await CustomerAnswerPresentationService(
|
||||
db,
|
||||
tenant_id=rag_ask_contract.resolve_effective_tenant_id(current_user, tenant_id),
|
||||
).present(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
payload=rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result),
|
||||
)
|
||||
payload = rag_ask_contract.sanitize_customer_rag_payload(
|
||||
presented_payload,
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
)
|
||||
if not str(payload.get("answer") or "").strip() and not str(payload.get("reply_text") or "").strip():
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
|
|
@ -95,7 +114,7 @@ async def rag_multi_step_search(
|
|||
@router.post("/symbols/sync")
|
||||
async def rag_symbols_sync(
|
||||
request: SymbolSyncRequest,
|
||||
current_user: User = Depends(require_roles("ADMIN", "BOSS")),
|
||||
current_user: User = Depends(require_roles("REVIEWER", "OWNER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from .helpers import _resolve_tenant_id
|
||||
from .shared import *
|
||||
from ...dependencies.permissions import require_manager_user
|
||||
from ...dependencies.permissions import require_platform_route_governance
|
||||
from ...services.adaptive_rag_service import AdaptiveRAGService
|
||||
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ from ...services.adaptive_rag_service import AdaptiveRAGService
|
|||
async def get_query_route_governance_summary(
|
||||
project_id: int | None = Query(default=None),
|
||||
window_days: int | None = Query(default=None),
|
||||
current_user: User = Depends(require_manager_user),
|
||||
current_user: User = Depends(require_platform_route_governance),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
|
|
@ -40,7 +40,7 @@ async def list_query_route_governance_records(
|
|||
search: str | None = Query(default=None),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
current_user: User = Depends(require_manager_user),
|
||||
current_user: User = Depends(require_platform_route_governance),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
|
|
@ -67,7 +67,7 @@ async def list_query_route_governance_records(
|
|||
async def get_query_route_governance_detail(
|
||||
record_id: int,
|
||||
project_id: int | None = Query(default=None),
|
||||
current_user: User = Depends(require_manager_user),
|
||||
current_user: User = Depends(require_platform_route_governance),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
|
|
@ -79,4 +79,3 @@ async def get_query_route_governance_detail(
|
|||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="query route record not found")
|
||||
return success_response(data)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,76 +4,83 @@ API 路由汇总
|
|||
统一注册所有 API 路由,供 main.py 引入。
|
||||
"""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .signals_api import router as signals_router
|
||||
from .feedbacks import router as feedbacks_router
|
||||
from .experiments import router as experiments_router
|
||||
from .context import router as context_router
|
||||
from .tasks import router as tasks_router
|
||||
from .webhooks import router as webhooks_router
|
||||
from .auth import router as auth_router
|
||||
from .users_api import router as users_router
|
||||
from .endpoints.skill import router as skill_router
|
||||
from .workloads import router as workloads_router
|
||||
from .skill_tags_api import router as skill_tags_router
|
||||
from .sstc_api import router as sstc_router
|
||||
from .projects_api import router as projects_router
|
||||
from .scheduler import router as scheduler_router
|
||||
from .ai_steward_api import router as ai_steward_router
|
||||
from .feishu_api import router as feishu_router
|
||||
from .wecom_api import router as wecom_router
|
||||
from .notifications import router as notifications_router
|
||||
from .llm_engines import router as llm_engines_router
|
||||
from .llm_providers import router as llm_providers_router
|
||||
from .ai_logs_api import router as ai_logs_router
|
||||
from .prompt_profiles import router as prompt_profiles_router
|
||||
from .rag_api import router as rag_router
|
||||
from .knowledge_graph import router as knowledge_graph_router
|
||||
from .recipes import router as recipes_router
|
||||
from .signal_sources_api import router as signal_sources_router
|
||||
from .operation_catalog import router as operation_catalog_router
|
||||
from .ai_pmo_api import router as ai_pmo_router
|
||||
from .audit import router as audit_router
|
||||
from .model_routing import router as model_routing_router
|
||||
from .semantic_facts_api import router as semantic_facts_router
|
||||
from .task_briefs import router as task_briefs_router
|
||||
from .mcp_api import router as mcp_router
|
||||
from ..core.config import settings
|
||||
|
||||
# 创建主 API 路由器
|
||||
api_router = APIRouter()
|
||||
CORE_ROUTE_MODULES = (
|
||||
"signals_api",
|
||||
"feedbacks",
|
||||
"experiments",
|
||||
"context",
|
||||
"tasks",
|
||||
"webhooks",
|
||||
"auth",
|
||||
"workspaces_api",
|
||||
"users_api",
|
||||
"endpoints.skill",
|
||||
"workloads",
|
||||
"skill_tags_api",
|
||||
"sstc_api",
|
||||
"projects_api",
|
||||
"scheduler",
|
||||
"notifications",
|
||||
"recipes",
|
||||
"operation_catalog",
|
||||
"audit",
|
||||
"task_briefs",
|
||||
"pmflow_os_api",
|
||||
"pmflow_launch_api",
|
||||
"space_api",
|
||||
"legacy_ai_steward_api",
|
||||
"account_settings_api",
|
||||
"ai_readiness_api",
|
||||
"ai_call_traces_api",
|
||||
"agent_run_audit_api",
|
||||
"prompt_assets_api",
|
||||
"ai_routing",
|
||||
"llm_engines",
|
||||
"model_routing",
|
||||
)
|
||||
|
||||
# 注册各模块路由
|
||||
api_router.include_router(signals_router)
|
||||
api_router.include_router(feedbacks_router)
|
||||
api_router.include_router(experiments_router)
|
||||
api_router.include_router(context_router)
|
||||
api_router.include_router(tasks_router)
|
||||
api_router.include_router(webhooks_router)
|
||||
api_router.include_router(auth_router)
|
||||
api_router.include_router(users_router)
|
||||
api_router.include_router(skill_router)
|
||||
api_router.include_router(workloads_router)
|
||||
api_router.include_router(skill_tags_router)
|
||||
api_router.include_router(sstc_router)
|
||||
api_router.include_router(projects_router)
|
||||
api_router.include_router(scheduler_router)
|
||||
api_router.include_router(ai_steward_router)
|
||||
api_router.include_router(feishu_router)
|
||||
api_router.include_router(wecom_router)
|
||||
api_router.include_router(notifications_router)
|
||||
api_router.include_router(llm_engines_router)
|
||||
api_router.include_router(llm_providers_router)
|
||||
api_router.include_router(ai_logs_router)
|
||||
api_router.include_router(prompt_profiles_router)
|
||||
api_router.include_router(rag_router)
|
||||
api_router.include_router(knowledge_graph_router)
|
||||
api_router.include_router(recipes_router)
|
||||
api_router.include_router(signal_sources_router)
|
||||
api_router.include_router(operation_catalog_router)
|
||||
api_router.include_router(ai_pmo_router)
|
||||
api_router.include_router(audit_router)
|
||||
api_router.include_router(model_routing_router)
|
||||
api_router.include_router(semantic_facts_router)
|
||||
api_router.include_router(task_briefs_router)
|
||||
api_router.include_router(mcp_router)
|
||||
OPTIONAL_ROUTE_GROUPS = {
|
||||
"integrations": ("feishu_api", "wecom_api", "mcp_api", "dev_sync"),
|
||||
"knowledge": ("rag_api", "knowledge_graph", "semantic_facts_api", "ai_pmo_api"),
|
||||
"ai_control": (
|
||||
"ai_steward_api",
|
||||
"llm_providers",
|
||||
"ai_logs_api",
|
||||
"prompt_profiles",
|
||||
"signal_sources_api",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _include_module(router: APIRouter, module_path: str) -> None:
|
||||
module = import_module(f"{__package__}.{module_path}")
|
||||
router.include_router(module.router)
|
||||
|
||||
|
||||
def _enabled_optional_groups() -> tuple[str, ...]:
|
||||
if settings.api_route_profile == "lean":
|
||||
return tuple(settings.api_optional_route_groups)
|
||||
if settings.api_optional_route_groups:
|
||||
return tuple(dict.fromkeys(settings.api_optional_route_groups))
|
||||
return tuple(OPTIONAL_ROUTE_GROUPS.keys())
|
||||
|
||||
|
||||
def build_api_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
for module_path in CORE_ROUTE_MODULES:
|
||||
_include_module(router, module_path)
|
||||
|
||||
for group in _enabled_optional_groups():
|
||||
for module_path in OPTIONAL_ROUTE_GROUPS.get(group, ()):
|
||||
_include_module(router, module_path)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
api_router = build_api_router()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from ...core.database import get_db
|
||||
from ...core.response import error_response, success_response
|
||||
from ...dependencies import require_manager_user
|
||||
from ...dependencies import require_platform_semantic_facts
|
||||
from ...models import User
|
||||
from ...services.semantic_fact_service import SemanticFactService
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ class SemanticFactsBackfillRequest(BaseModel):
|
|||
@router.get("/admin/overview")
|
||||
async def get_semantic_facts_overview(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_manager_user),
|
||||
current_user: User = Depends(require_platform_semantic_facts),
|
||||
):
|
||||
service = SemanticFactService(db, tenant_id=int(current_user.tenant_id))
|
||||
return success_response(await service.get_admin_overview())
|
||||
|
|
@ -37,7 +37,7 @@ async def list_semantic_fact_objects(
|
|||
q: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_manager_user),
|
||||
current_user: User = Depends(require_platform_semantic_facts),
|
||||
):
|
||||
service = SemanticFactService(db, tenant_id=int(current_user.tenant_id))
|
||||
items = await service.list_admin_objects(
|
||||
|
|
@ -53,7 +53,7 @@ async def get_semantic_fact_object_detail(
|
|||
entity_type: str,
|
||||
entity_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_manager_user),
|
||||
current_user: User = Depends(require_platform_semantic_facts),
|
||||
):
|
||||
service = SemanticFactService(db, tenant_id=int(current_user.tenant_id))
|
||||
try:
|
||||
|
|
@ -68,7 +68,7 @@ async def reextract_semantic_fact_object(
|
|||
entity_type: str,
|
||||
entity_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_manager_user),
|
||||
current_user: User = Depends(require_platform_semantic_facts),
|
||||
):
|
||||
service = SemanticFactService(db, tenant_id=int(current_user.tenant_id))
|
||||
row = await service.enqueue_entity_event(
|
||||
|
|
@ -92,7 +92,7 @@ async def reextract_semantic_fact_object(
|
|||
async def enqueue_semantic_fact_backfill(
|
||||
body: SemanticFactsBackfillRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_manager_user),
|
||||
current_user: User = Depends(require_platform_semantic_facts),
|
||||
):
|
||||
service = SemanticFactService(db, tenant_id=int(current_user.tenant_id))
|
||||
result = await service.enqueue_backfill(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,401 @@
|
|||
"""食谱系统信号源 API
|
||||
|
||||
功能:
|
||||
1. 接收食谱系统的 webhook 请求
|
||||
2. 将食谱识别需求转换为 PMFlow 信号
|
||||
3. 自动关联到指定项目(食谱系统项目)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import json
|
||||
|
||||
from ..database import get_db
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..core.logging import get_logger
|
||||
from ..models import Signal, ProjectSignalLink, Project
|
||||
|
||||
router = APIRouter(prefix="/signal-sources", tags=["Signal Sources"])
|
||||
logger = get_logger(__name__)
|
||||
|
||||
RECIPE_PROJECT_KEYWORDS = ("食谱", "recipe", "sstc")
|
||||
|
||||
|
||||
async def _resolve_target_project_id(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: int,
|
||||
project_id: Optional[int],
|
||||
) -> int:
|
||||
"""解析目标项目:优先显式 project_id,其次按关键字自动匹配。"""
|
||||
from sqlalchemy import select, or_
|
||||
|
||||
if project_id is not None:
|
||||
result = await db.execute(
|
||||
select(Project.id).where(
|
||||
Project.tenant_id == tenant_id,
|
||||
Project.id == int(project_id),
|
||||
).limit(1)
|
||||
)
|
||||
found = result.scalar_one_or_none()
|
||||
if found is None:
|
||||
raise HTTPException(status_code=404, detail=f"project_id={project_id} 不存在或无权限")
|
||||
return int(found)
|
||||
|
||||
keyword_conditions = []
|
||||
for keyword in RECIPE_PROJECT_KEYWORDS:
|
||||
keyword_conditions.extend([
|
||||
Project.title.ilike(f"%{keyword}%"),
|
||||
Project.name.ilike(f"%{keyword}%"),
|
||||
])
|
||||
|
||||
match_result = await db.execute(
|
||||
select(Project.id).where(
|
||||
Project.tenant_id == tenant_id,
|
||||
or_(*keyword_conditions),
|
||||
).limit(1)
|
||||
)
|
||||
matched = match_result.scalar_one_or_none()
|
||||
if matched is not None:
|
||||
return int(matched)
|
||||
|
||||
# 兜底:若租户仅存在一个活跃项目,自动绑定,避免“已调用但未入库”。
|
||||
active_result = await db.execute(
|
||||
select(Project.id).where(
|
||||
Project.tenant_id == tenant_id,
|
||||
Project.status == "active",
|
||||
).limit(2)
|
||||
)
|
||||
active_ids = list(active_result.scalars().all())
|
||||
if len(active_ids) == 1:
|
||||
return int(active_ids[0])
|
||||
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="未找到目标项目,请在请求中显式传入 project_id(或确保项目名包含 食谱/recipe/sstc)",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 食谱系统信号源 Webhook
|
||||
# ============================================================================
|
||||
|
||||
class RecipeRecognitionRequest(BaseModel):
|
||||
"""食谱识别请求"""
|
||||
# 食谱信息
|
||||
food_name: str = Field(..., description="食物名称")
|
||||
food_description: Optional[str] = Field(None, description="食物描述")
|
||||
image_url: Optional[str] = Field(None, description="图片URL")
|
||||
category: Optional[str] = Field(None, description="分类")
|
||||
cuisine: Optional[str] = Field(None, description="菜系")
|
||||
|
||||
# 营养信息(如果来源系统已分析)
|
||||
calories: Optional[float] = Field(None, description="热量(千卡)")
|
||||
protein: Optional[float] = Field(None, description="蛋白质(克)")
|
||||
fat: Optional[float] = Field(None, description="脂肪(克)")
|
||||
carbs: Optional[float] = Field(None, description="碳水化合物(克)")
|
||||
fiber: Optional[float] = Field(None, description="膳食纤维(克)")
|
||||
|
||||
# 意图
|
||||
intent: str = Field(
|
||||
default="analyze",
|
||||
description="意图:analyze-热量分析, save-保存食谱, recommend-推荐菜谱"
|
||||
)
|
||||
|
||||
# 用户信息(可选)
|
||||
user_name: Optional[str] = Field(None, description="用户名")
|
||||
|
||||
|
||||
class RecipeRecognitionResponse(BaseModel):
|
||||
"""响应"""
|
||||
success: bool
|
||||
signal_id: Optional[int] = None
|
||||
project_id: Optional[int] = None
|
||||
message: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@router.post("/recipe/webhook", response_model=RecipeRecognitionResponse, summary="食谱系统 Webhook")
|
||||
async def recipe_webhook(
|
||||
request: RecipeRecognitionRequest,
|
||||
project_id: Optional[int] = Query(None, description="项目ID,不传则自动查找食谱系统项目"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""
|
||||
接收食谱系统的识别请求
|
||||
|
||||
用法:
|
||||
- 食谱系统完成拍照识别后,调用此 API 将需求推送到 PMFlow
|
||||
- PMFlow 会自动创建信号并关联到食谱系统项目
|
||||
|
||||
请求示例:
|
||||
```json
|
||||
{
|
||||
"food_name": "红烧肉",
|
||||
"food_description": "一块红烧肉,约200克,肥瘦相间",
|
||||
"image_url": "https://example.com/image.jpg",
|
||||
"category": "肉类",
|
||||
"cuisine": "中式",
|
||||
"calories": 350,
|
||||
"protein": 20,
|
||||
"fat": 25,
|
||||
"carbs": 10,
|
||||
"fiber": 0,
|
||||
"intent": "analyze",
|
||||
"user_name": "张三"
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# 1. 查找目标项目
|
||||
target_project_id = await _resolve_target_project_id(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# 2. 构建信号内容
|
||||
content_parts = []
|
||||
content_parts.append(f"食物:{request.food_name}")
|
||||
|
||||
if request.food_description:
|
||||
content_parts.append(f"描述:{request.food_description}")
|
||||
|
||||
# 营养信息
|
||||
if request.calories or request.protein or request.fat or request.carbs or request.fiber:
|
||||
nutrition = []
|
||||
if request.calories:
|
||||
nutrition.append(f"热量 {request.calories} kcal")
|
||||
if request.protein:
|
||||
nutrition.append(f"蛋白 {request.protein}g")
|
||||
if request.fat:
|
||||
nutrition.append(f"脂肪 {request.fat}g")
|
||||
if request.carbs:
|
||||
nutrition.append(f"碳水 {request.carbs}g")
|
||||
if request.fiber:
|
||||
nutrition.append(f"纤维 {request.fiber}g")
|
||||
content_parts.append("营养:" + ", ".join(nutrition))
|
||||
|
||||
if request.category:
|
||||
content_parts.append(f"分类:{request.category}")
|
||||
if request.cuisine:
|
||||
content_parts.append(f"菜系:{request.cuisine}")
|
||||
content_parts.append(f"意图:{request.intent}")
|
||||
|
||||
content = "\n".join(content_parts)
|
||||
|
||||
# 3. 构建标题
|
||||
intent_text = {
|
||||
"analyze": "热量分析",
|
||||
"save": "保存食谱",
|
||||
"recommend": "推荐菜谱"
|
||||
}.get(request.intent, request.intent)
|
||||
|
||||
title = f"🍽️ 食谱{intent_text}:{request.food_name}"
|
||||
|
||||
# 4. 构建摘要
|
||||
summary = f"食物:{request.food_name}"
|
||||
if request.calories:
|
||||
summary += f"\n热量:{request.calories} kcal"
|
||||
if request.category:
|
||||
summary += f"\n分类:{request.category}"
|
||||
summary += f"\n意图:{intent_text}"
|
||||
|
||||
# 5. 构建信号元数据
|
||||
signal_metadata = {
|
||||
"source": "recipe_system",
|
||||
"intent": request.intent,
|
||||
"category": request.category,
|
||||
"cuisine": request.cuisine,
|
||||
"nutrition": {
|
||||
"calories": request.calories,
|
||||
"protein": request.protein,
|
||||
"fat": request.fat,
|
||||
"carbs": request.carbs,
|
||||
"fiber": request.fiber,
|
||||
},
|
||||
"image_url": request.image_url,
|
||||
"user_name": request.user_name,
|
||||
}
|
||||
|
||||
# 6. 创建信号
|
||||
signal = Signal(
|
||||
tenant_id=tenant_id,
|
||||
source="recipe_system",
|
||||
title=title,
|
||||
content=content,
|
||||
summary=summary,
|
||||
signal_type="recipe_recognition",
|
||||
priority="medium",
|
||||
impact_score=0.5,
|
||||
status="待处理",
|
||||
processed=False,
|
||||
strategic_analysis=json.dumps(signal_metadata, ensure_ascii=False),
|
||||
)
|
||||
|
||||
db.add(signal)
|
||||
await db.flush() # 获取 signal.id
|
||||
|
||||
# 7. 关联到项目
|
||||
link = ProjectSignalLink(
|
||||
tenant_id=tenant_id,
|
||||
project_id=target_project_id,
|
||||
signal_id=signal.id,
|
||||
)
|
||||
db.add(link)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(signal)
|
||||
|
||||
return RecipeRecognitionResponse(
|
||||
success=True,
|
||||
signal_id=signal.id,
|
||||
project_id=target_project_id,
|
||||
message="信号创建成功并已关联到食谱系统项目",
|
||||
details={
|
||||
"signal_id": signal.id,
|
||||
"project_id": target_project_id,
|
||||
"title": title,
|
||||
"status": signal.status,
|
||||
}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
"recipe_webhook_create_signal_failed",
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
food_name=request.food_name,
|
||||
error=str(e),
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"创建信号失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/recipe/status/{signal_id}", summary="查询食谱信号状态")
|
||||
async def get_recipe_signal_status(
|
||||
signal_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""查询食谱识别信号的处理状态"""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(Signal).where(
|
||||
Signal.id == signal_id,
|
||||
Signal.tenant_id == tenant_id,
|
||||
Signal.source == "recipe_system",
|
||||
)
|
||||
)
|
||||
signal = result.scalar_one_or_none()
|
||||
|
||||
if not signal:
|
||||
raise HTTPException(status_code=404, detail="信号不存在")
|
||||
|
||||
return {
|
||||
"signal_id": signal.id,
|
||||
"title": signal.title,
|
||||
"status": signal.status,
|
||||
"processed": signal.processed,
|
||||
"summary": signal.summary,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 批量导入食谱记录
|
||||
# ============================================================================
|
||||
|
||||
class RecipeBatchRequest(BaseModel):
|
||||
"""批量导入请求"""
|
||||
recipes: list[RecipeRecognitionRequest] = Field(..., description="食谱列表")
|
||||
|
||||
|
||||
@router.post("/recipe/batch", response_model=RecipeRecognitionResponse, summary="批量导入食谱")
|
||||
async def recipe_batch_import(
|
||||
request: RecipeBatchRequest,
|
||||
project_id: Optional[int] = Query(None, description="项目ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""批量导入食谱记录,每个都会创建信号"""
|
||||
try:
|
||||
target_project_id = await _resolve_target_project_id(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
signal_ids = []
|
||||
for recipe in request.recipes:
|
||||
# 构建内容
|
||||
content = f"食物:{recipe.food_name}"
|
||||
if recipe.calories:
|
||||
content += f"\n热量:{recipe.calories} kcal"
|
||||
|
||||
title = f"🍽️ 食谱记录:{recipe.food_name}"
|
||||
|
||||
metadata = {
|
||||
"source": "recipe_system",
|
||||
"intent": recipe.intent,
|
||||
"nutrition": {
|
||||
"calories": recipe.calories,
|
||||
"protein": recipe.protein,
|
||||
"fat": recipe.fat,
|
||||
"carbs": recipe.carbs,
|
||||
}
|
||||
}
|
||||
|
||||
signal = Signal(
|
||||
tenant_id=tenant_id,
|
||||
source="recipe_system",
|
||||
title=title,
|
||||
content=content,
|
||||
summary=f"食物:{recipe.food_name}",
|
||||
signal_type="recipe_recognition",
|
||||
priority="low",
|
||||
impact_score=0.3,
|
||||
status="待处理",
|
||||
processed=True, # 批量导入的视为已处理
|
||||
strategic_analysis=json.dumps(metadata),
|
||||
)
|
||||
db.add(signal)
|
||||
await db.flush()
|
||||
|
||||
# 关联项目
|
||||
link = ProjectSignalLink(
|
||||
tenant_id=tenant_id,
|
||||
project_id=target_project_id,
|
||||
signal_id=signal.id,
|
||||
)
|
||||
db.add(link)
|
||||
signal_ids.append(signal.id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return RecipeRecognitionResponse(
|
||||
success=True,
|
||||
signal_id=len(signal_ids), # 返回数量
|
||||
project_id=target_project_id,
|
||||
message=f"成功导入 {len(signal_ids)} 条食谱记录",
|
||||
details={"signal_count": len(signal_ids)}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
"recipe_batch_import_failed",
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
recipe_count=len(request.recipes),
|
||||
error=str(e),
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}")
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
"""信号相关 API 路由"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from ..database import get_db
|
||||
from ..dependencies.tenant import get_tenant_id
|
||||
from ..models import Signal, SignalFeedback, Project, ProjectSignalLink
|
||||
from ..schemas import (
|
||||
SignalCreate,
|
||||
SignalPriority,
|
||||
classify_signal_priority,
|
||||
SignalFeedbackCreate,
|
||||
SignalFeedbackResponse,
|
||||
)
|
||||
from ..services.fetcher import fetch_hn_stories
|
||||
from ..services.analyzer import analyze_impact
|
||||
from ..services.llm import llm_service
|
||||
from ..services.plane import plane_service
|
||||
from ..services.lark import LarkService
|
||||
|
||||
router = APIRouter(prefix="/signals", tags=["signals"])
|
||||
lark_service = LarkService()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_signals(
|
||||
roles: Optional[str] = Query(None, description="逗号分隔的角色列表,如 'frontend,backend'"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取信号列表,支持按角色过滤"""
|
||||
result = await db.execute(select(Signal).order_by(Signal.impact_score.desc()))
|
||||
signals = result.scalars().all()
|
||||
|
||||
if roles:
|
||||
role_list = [r.strip() for r in roles.split(",")]
|
||||
filtered_signals = []
|
||||
for signal in signals:
|
||||
tasks = []
|
||||
if signal.tasks_generated:
|
||||
try:
|
||||
tasks = json.loads(signal.tasks_generated)
|
||||
except Exception:
|
||||
tasks = []
|
||||
|
||||
filtered_tasks = [t for t in tasks if t.get("type") in role_list]
|
||||
|
||||
if filtered_tasks:
|
||||
signal_dict = {
|
||||
"id": str(signal.id),
|
||||
"source": signal.source,
|
||||
"content": signal.content,
|
||||
"impact_score": signal.impact_score,
|
||||
"priority": signal.priority,
|
||||
"processed": signal.processed,
|
||||
"strategic_analysis": signal.strategic_analysis,
|
||||
"tasks_generated": signal.tasks_generated,
|
||||
"tasks_filtered": filtered_tasks,
|
||||
"created_at": signal.created_at.isoformat() if signal.created_at else None,
|
||||
}
|
||||
filtered_signals.append(signal_dict)
|
||||
|
||||
return filtered_signals
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
async def create_signal(signal: SignalCreate, db: AsyncSession = Depends(get_db)):
|
||||
new_signal = Signal(
|
||||
source=signal.source,
|
||||
content=signal.content,
|
||||
impact_score=signal.impact_score,
|
||||
)
|
||||
db.add(new_signal)
|
||||
await db.commit()
|
||||
await db.refresh(new_signal)
|
||||
return {"id": str(new_signal.id), "status": "received"}
|
||||
|
||||
|
||||
@router.post("/ingest", response_model=dict)
|
||||
async def ingest_signal(
|
||||
signal: SignalCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""接入外部信号,按优先级处理"""
|
||||
initial_priority = signal.priority or classify_signal_priority(signal.impact_score)
|
||||
|
||||
new_signal = Signal(
|
||||
source=signal.source,
|
||||
content=signal.content,
|
||||
impact_score=signal.impact_score,
|
||||
priority=initial_priority.value,
|
||||
)
|
||||
db.add(new_signal)
|
||||
await db.commit()
|
||||
await db.refresh(new_signal)
|
||||
|
||||
if initial_priority == SignalPriority.LOW:
|
||||
return {
|
||||
"id": str(new_signal.id),
|
||||
"priority": initial_priority.value,
|
||||
"status": "recorded",
|
||||
"message": "Low priority signal recorded without processing",
|
||||
}
|
||||
|
||||
async def process_signal_internal():
|
||||
try:
|
||||
relevant_context = await llm_service.find_relevant_context(
|
||||
signal_content=signal.content,
|
||||
db_session=db,
|
||||
top_k=3,
|
||||
min_similarity=0.3,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
strategy_result = await llm_service.evaluate_with_voting_async(
|
||||
signal_content=signal.content,
|
||||
relevant_context=relevant_context,
|
||||
impact_score=signal.impact_score,
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
except RuntimeError:
|
||||
if initial_priority == SignalPriority.CRITICAL:
|
||||
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
|
||||
return {
|
||||
"id": str(new_signal.id),
|
||||
"priority": initial_priority.value,
|
||||
"status": "blocked",
|
||||
"processing_mode": "background",
|
||||
"message": "预算超限,AI 调用被阻止(hard limit)",
|
||||
}
|
||||
|
||||
is_strategic = strategy_result.get("is_strategic", False)
|
||||
decision_proposal = strategy_result.get("decision_proposal", {})
|
||||
tasks = strategy_result.get("tasks", [])
|
||||
confidence = strategy_result.get("confidence", 0.0)
|
||||
|
||||
final_priority = classify_signal_priority(signal.impact_score, confidence)
|
||||
new_signal.priority = final_priority.value
|
||||
|
||||
sync_result = None
|
||||
notification_sent = False
|
||||
|
||||
if final_priority in [SignalPriority.CRITICAL, SignalPriority.HIGH]:
|
||||
if is_strategic and tasks:
|
||||
sync_result = await plane_service.sync_tasks(
|
||||
tasks, is_urgent=(final_priority == SignalPriority.CRITICAL)
|
||||
)
|
||||
default_chat_id = "oc_1234567890"
|
||||
await lark_service.send_strategic_alert(
|
||||
default_chat_id, signal.content, decision_proposal, tasks
|
||||
)
|
||||
notification_sent = True
|
||||
new_signal.impact_score = 1.0
|
||||
|
||||
analysis_data = {
|
||||
"decision_proposal": decision_proposal,
|
||||
"competitiveness_radar": strategy_result.get("competitiveness_radar"),
|
||||
"detected_tech_stacks": strategy_result.get("detected_tech_stacks", []),
|
||||
"related_knowledge_nodes": strategy_result.get("related_knowledge_nodes", []),
|
||||
"rag_context_used": len(relevant_context) if relevant_context else 0,
|
||||
"rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])],
|
||||
}
|
||||
|
||||
if "_voting" in strategy_result:
|
||||
voting_info = strategy_result["_voting"]
|
||||
analysis_data["voting"] = {
|
||||
"enabled": voting_info.get("enabled"),
|
||||
"models_used": voting_info.get("models_used"),
|
||||
"strategic_votes": voting_info.get("strategic_votes"),
|
||||
"total_votes": voting_info.get("total_votes"),
|
||||
"unanimous": voting_info.get("unanimous"),
|
||||
}
|
||||
|
||||
new_signal.strategic_analysis = json.dumps(analysis_data)
|
||||
new_signal.tasks_generated = json.dumps(tasks)
|
||||
new_signal.processed = True
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_signal)
|
||||
|
||||
return {
|
||||
"id": str(new_signal.id),
|
||||
"priority": final_priority.value,
|
||||
"is_strategic": is_strategic,
|
||||
"confidence": confidence,
|
||||
"decision_proposal": decision_proposal,
|
||||
"rag_context_used": len(relevant_context) if relevant_context else 0,
|
||||
"voting": strategy_result.get("_voting"),
|
||||
"tasks_generated": len(tasks),
|
||||
"sync_result": sync_result,
|
||||
"notification_sent": notification_sent,
|
||||
}
|
||||
|
||||
if initial_priority == SignalPriority.CRITICAL:
|
||||
result = await process_signal_internal()
|
||||
result["processing_mode"] = "synchronous"
|
||||
return result
|
||||
else:
|
||||
background_tasks.add_task(process_signal_internal)
|
||||
return {
|
||||
"id": str(new_signal.id),
|
||||
"priority": initial_priority.value,
|
||||
"status": "queued",
|
||||
"processing_mode": "background",
|
||||
"message": f"{initial_priority.value.upper()} priority signal queued for processing",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch", response_model=dict)
|
||||
async def trigger_signal_fetch(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
"""手动触发抓取并分析信号"""
|
||||
|
||||
def _stop_ollama():
|
||||
import subprocess
|
||||
import signal
|
||||
import os
|
||||
|
||||
killed = False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "ollama"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for pid in result.stdout.strip().split("\n"):
|
||||
if pid:
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGTERM)
|
||||
killed = True
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[AI] pgrep failed: {e}")
|
||||
|
||||
if not killed:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pkill", "-9", "-f", "ollama"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
killed = result.returncode == 0
|
||||
except Exception as e:
|
||||
print(f"[AI] pkill failed: {e}")
|
||||
|
||||
if killed:
|
||||
print("[AI] Ollama stopped - analysis complete")
|
||||
|
||||
raw_stories = await fetch_hn_stories(limit=30)
|
||||
new_signals_count = 0
|
||||
processed_count = 0
|
||||
|
||||
for story in raw_stories:
|
||||
result = await db.execute(select(Signal).where(Signal.content == story["content"]))
|
||||
if result.scalars().first():
|
||||
continue
|
||||
|
||||
impact = analyze_impact(story["content"])
|
||||
|
||||
if impact > 0.4:
|
||||
initial_priority = classify_signal_priority(impact)
|
||||
|
||||
new_signal = Signal(
|
||||
source=story["source"],
|
||||
content=story["content"],
|
||||
impact_score=impact,
|
||||
priority=initial_priority.value,
|
||||
)
|
||||
db.add(new_signal)
|
||||
await db.commit()
|
||||
await db.refresh(new_signal)
|
||||
new_signals_count += 1
|
||||
|
||||
if initial_priority == SignalPriority.LOW:
|
||||
continue
|
||||
|
||||
async def process_fetched():
|
||||
nonlocal processed_count
|
||||
try:
|
||||
relevant_context = await llm_service.find_relevant_context(
|
||||
signal_content=story["content"],
|
||||
db_session=db,
|
||||
top_k=3,
|
||||
min_similarity=0.3,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
strategy_result = await llm_service.evaluate_with_voting_async(
|
||||
signal_content=story["content"],
|
||||
relevant_context=relevant_context,
|
||||
impact_score=impact,
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
except RuntimeError:
|
||||
return
|
||||
is_strategic = strategy_result.get("is_strategic", False)
|
||||
decision_proposal = strategy_result.get("decision_proposal", {})
|
||||
confidence = strategy_result.get("confidence", 0.0)
|
||||
final_priority = classify_signal_priority(impact, confidence)
|
||||
new_signal.priority = final_priority.value
|
||||
|
||||
analysis_data = {
|
||||
"decision_proposal": decision_proposal,
|
||||
"competitiveness_radar": strategy_result.get("competitiveness_radar"),
|
||||
"detected_tech_stacks": strategy_result.get("detected_tech_stacks", []),
|
||||
"related_knowledge_nodes": strategy_result.get(
|
||||
"related_knowledge_nodes", []
|
||||
),
|
||||
"rag_context_used": len(relevant_context) if relevant_context else 0,
|
||||
"rag_context_sources": [ctx["id"] for ctx in (relevant_context or [])],
|
||||
"confidence": confidence,
|
||||
"is_strategic": is_strategic,
|
||||
}
|
||||
if "_voting" in strategy_result:
|
||||
vi = strategy_result["_voting"]
|
||||
analysis_data["voting"] = {
|
||||
"enabled": vi.get("enabled"),
|
||||
"models_used": vi.get("models_used"),
|
||||
"strategic_votes": vi.get("strategic_votes"),
|
||||
"total_votes": vi.get("total_votes"),
|
||||
"unanimous": vi.get("unanimous"),
|
||||
}
|
||||
new_signal.strategic_analysis = json.dumps(analysis_data)
|
||||
new_signal.tasks_generated = json.dumps(strategy_result.get("tasks", []))
|
||||
new_signal.processed = True
|
||||
await db.commit()
|
||||
processed_count += 1
|
||||
|
||||
if initial_priority in [SignalPriority.MEDIUM, SignalPriority.HIGH]:
|
||||
background_tasks.add_task(process_fetched)
|
||||
else:
|
||||
await process_fetched()
|
||||
|
||||
_stop_ollama()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"new_signals_count": new_signals_count,
|
||||
"processed_count": processed_count,
|
||||
"message": f"Fetched {new_signals_count} signals, {processed_count} analyzed",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{signal_id}/projects")
|
||||
async def get_signal_projects(
|
||||
signal_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取信号关联的项目"""
|
||||
try:
|
||||
sid = int(signal_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid signal_id")
|
||||
|
||||
signal_result = await db.execute(select(Signal).where(Signal.id == sid))
|
||||
if not signal_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Signal not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(Project, ProjectSignalLink)
|
||||
.join(ProjectSignalLink, Project.id == ProjectSignalLink.project_id)
|
||||
.where(ProjectSignalLink.signal_id == sid)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(p.id),
|
||||
"title": p.title,
|
||||
"status": p.status,
|
||||
"assigned_role": p.assigned_role,
|
||||
"link_type": link.link_type,
|
||||
"relevance_score": link.relevance_score,
|
||||
}
|
||||
for p, link in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{signal_id}/feedback", response_model=SignalFeedbackResponse)
|
||||
async def submit_signal_feedback(
|
||||
signal_id: int,
|
||||
feedback: SignalFeedbackCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""提交信号反馈"""
|
||||
result = await db.execute(select(Signal).where(Signal.id == signal_id))
|
||||
signal = result.scalar_one_or_none()
|
||||
if not signal:
|
||||
raise HTTPException(status_code=404, detail="Signal not found")
|
||||
|
||||
ai_predicted_strategic = False
|
||||
ai_confidence = 0.0
|
||||
if signal.strategic_analysis:
|
||||
try:
|
||||
analysis = json.loads(signal.strategic_analysis)
|
||||
ai_predicted_strategic = analysis.get("is_strategic", signal.impact_score >= 0.9)
|
||||
ai_confidence = analysis.get("confidence", 0.0)
|
||||
except Exception:
|
||||
ai_predicted_strategic = signal.impact_score >= 0.9
|
||||
|
||||
new_feedback = SignalFeedback(
|
||||
signal_id=signal_id,
|
||||
was_strategic=feedback.was_strategic,
|
||||
reason=feedback.reason,
|
||||
user_id=feedback.user_id,
|
||||
ai_predicted_strategic=ai_predicted_strategic,
|
||||
ai_confidence=ai_confidence,
|
||||
)
|
||||
db.add(new_feedback)
|
||||
await db.commit()
|
||||
await db.refresh(new_feedback)
|
||||
|
||||
is_correct = ai_predicted_strategic == feedback.was_strategic
|
||||
|
||||
return SignalFeedbackResponse(
|
||||
id=new_feedback.id,
|
||||
signal_id=new_feedback.signal_id,
|
||||
was_strategic=new_feedback.was_strategic,
|
||||
ai_predicted_strategic=new_feedback.ai_predicted_strategic,
|
||||
ai_confidence=new_feedback.ai_confidence,
|
||||
is_correct=is_correct,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{signal_id}/feedbacks", response_model=List[SignalFeedbackResponse])
|
||||
async def get_signal_feedbacks(
|
||||
signal_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取某信号的全部反馈"""
|
||||
result = await db.execute(
|
||||
select(SignalFeedback)
|
||||
.where(SignalFeedback.signal_id == signal_id)
|
||||
.order_by(SignalFeedback.created_at.desc())
|
||||
)
|
||||
feedbacks = result.scalars().all()
|
||||
|
||||
return [
|
||||
SignalFeedbackResponse(
|
||||
id=f.id,
|
||||
signal_id=f.signal_id,
|
||||
was_strategic=f.was_strategic,
|
||||
ai_predicted_strategic=f.ai_predicted_strategic,
|
||||
ai_confidence=f.ai_confidence or 0.0,
|
||||
is_correct=f.ai_predicted_strategic == f.was_strategic,
|
||||
)
|
||||
for f in feedbacks
|
||||
]
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
"""技能标签与用户技能绑定标准路由。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..dependencies import get_current_user, get_tenant_id, require_roles
|
||||
from ..models import SkillTag, User, UserSkillBinding
|
||||
from ..services import skill_service
|
||||
from ..services.auth import has_any_role
|
||||
|
||||
router = APIRouter(tags=["skill-tags"])
|
||||
|
||||
|
||||
class SkillTagCreateRequest(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class SkillTagUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class UserSkillCreateRequest(BaseModel):
|
||||
skill_tag_id: int
|
||||
level: str = "beginner"
|
||||
proficiency: int = Field(default=50, ge=0, le=100)
|
||||
quality_score: float = 0.0
|
||||
efficiency_score: float = 0.0
|
||||
|
||||
|
||||
class UserSkillUpdateRequest(BaseModel):
|
||||
level: Optional[str] = None
|
||||
proficiency: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
quality_score: Optional[float] = None
|
||||
efficiency_score: Optional[float] = None
|
||||
|
||||
|
||||
def _to_tag_payload(tag: SkillTag) -> dict:
|
||||
return {
|
||||
"id": int(tag.id),
|
||||
"tenant_id": int(tag.tenant_id),
|
||||
"name": tag.name,
|
||||
"slug": tag.slug,
|
||||
"category": tag.category,
|
||||
"description": tag.description,
|
||||
"is_system": bool(tag.is_system),
|
||||
"sort_order": int(tag.sort_order or 0),
|
||||
"created_at": tag.created_at.isoformat() if tag.created_at else None,
|
||||
"updated_at": tag.updated_at.isoformat() if tag.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/skill-tags")
|
||||
async def list_skill_tags(
|
||||
category: Optional[str] = Query(None),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
tags = await skill_service.list_skill_tags(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
category=category,
|
||||
include_system=True,
|
||||
)
|
||||
count_rows = await db.execute(
|
||||
select(UserSkillBinding.skill_tag_id, func.count(UserSkillBinding.id))
|
||||
.where(UserSkillBinding.tenant_id == tenant_id)
|
||||
.group_by(UserSkillBinding.skill_tag_id)
|
||||
)
|
||||
count_map = {int(skill_id): int(total) for skill_id, total in count_rows.all()}
|
||||
items = []
|
||||
for t in tags:
|
||||
payload = _to_tag_payload(t)
|
||||
payload["linked_user_count"] = count_map.get(int(t.id), 0)
|
||||
items.append(payload)
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.post("/skill-tags")
|
||||
async def create_skill_tag(
|
||||
body: SkillTagCreateRequest,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
try:
|
||||
tag = await skill_service.create_skill_tag(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
name=body.name,
|
||||
slug=body.slug,
|
||||
category=body.category,
|
||||
description=body.description,
|
||||
sort_order=body.sort_order,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
return _to_tag_payload(tag)
|
||||
|
||||
|
||||
@router.put("/skill-tags/{tag_id}")
|
||||
async def update_skill_tag(
|
||||
tag_id: int,
|
||||
body: SkillTagUpdateRequest,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
tag = await skill_service.get_skill_tag(db, tag_id, tenant_id)
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Skill tag not found")
|
||||
|
||||
# 系统标签仅允许改分类,禁止改名。
|
||||
if tag.is_system and body.name is not None and body.name != tag.name:
|
||||
raise HTTPException(status_code=400, detail="System tag name cannot be changed")
|
||||
|
||||
if body.name is not None and not tag.is_system:
|
||||
tag.name = body.name
|
||||
if body.category is not None:
|
||||
tag.category = body.category
|
||||
if body.description is not None and not tag.is_system:
|
||||
tag.description = body.description
|
||||
if body.sort_order is not None:
|
||||
tag.sort_order = body.sort_order
|
||||
tag.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return _to_tag_payload(tag)
|
||||
|
||||
|
||||
@router.delete("/skill-tags/{tag_id}")
|
||||
async def delete_skill_tag(
|
||||
tag_id: int,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
tag = await skill_service.get_skill_tag(db, tag_id, tenant_id)
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Skill tag not found")
|
||||
if tag.is_system:
|
||||
raise HTTPException(status_code=400, detail="System tag cannot be deleted")
|
||||
|
||||
await db.execute(
|
||||
delete(UserSkillBinding).where(
|
||||
and_(
|
||||
UserSkillBinding.tenant_id == tenant_id,
|
||||
UserSkillBinding.skill_tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.delete(tag)
|
||||
await db.commit()
|
||||
return {"message": "Skill tag deleted"}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/skills")
|
||||
async def get_user_skills(
|
||||
user_id: int,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
bindings = await skill_service.get_user_skill_bindings(db, tenant_id=tenant_id, user_id=user_id)
|
||||
items = []
|
||||
for row in bindings:
|
||||
items.append(
|
||||
{
|
||||
"skill_tag_id": int(row.skill_tag_id),
|
||||
"skill_name": row.skill_tag.name if row.skill_tag else "",
|
||||
"category": row.skill_tag.category if row.skill_tag else None,
|
||||
"level": row.level or row.proficiency_level,
|
||||
"proficiency": int(row.proficiency or 0),
|
||||
"quality_score": float(row.quality_score or 0.0),
|
||||
"efficiency_score": float(row.efficiency_score or 0.0),
|
||||
"last_used_at": row.last_used_at.isoformat() if row.last_used_at else None,
|
||||
"is_system": bool(row.skill_tag.is_system) if row.skill_tag else False,
|
||||
}
|
||||
)
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/skills")
|
||||
async def bind_user_skill(
|
||||
user_id: int,
|
||||
body: UserSkillCreateRequest,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
exists = (
|
||||
await db.execute(
|
||||
select(UserSkillBinding).where(
|
||||
and_(
|
||||
UserSkillBinding.tenant_id == tenant_id,
|
||||
UserSkillBinding.user_id == user_id,
|
||||
UserSkillBinding.skill_tag_id == body.skill_tag_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="Skill already bound")
|
||||
|
||||
row = UserSkillBinding(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
skill_tag_id=body.skill_tag_id,
|
||||
level=body.level,
|
||||
proficiency_level=body.level,
|
||||
proficiency=body.proficiency,
|
||||
quality_score=body.quality_score,
|
||||
efficiency_score=body.efficiency_score,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
return {"message": "Skill bound"}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/skills/{skill_id}")
|
||||
async def update_user_skill(
|
||||
user_id: int,
|
||||
skill_id: int,
|
||||
body: UserSkillUpdateRequest,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
current_role = str(current_user.role or "")
|
||||
is_manager = has_any_role(current_role, ["BOSS", "ADMIN"])
|
||||
is_self = int(current_user.id) == user_id
|
||||
if not is_manager and not is_self:
|
||||
raise HTTPException(status_code=403, detail="No permission")
|
||||
|
||||
if is_self and not is_manager:
|
||||
if body.quality_score is not None or body.efficiency_score is not None:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Members can only update own proficiency",
|
||||
)
|
||||
|
||||
row = (
|
||||
await db.execute(
|
||||
select(UserSkillBinding).where(
|
||||
and_(
|
||||
UserSkillBinding.tenant_id == tenant_id,
|
||||
UserSkillBinding.user_id == user_id,
|
||||
UserSkillBinding.skill_tag_id == skill_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Binding not found")
|
||||
|
||||
if body.level is not None:
|
||||
row.level = body.level
|
||||
row.proficiency_level = body.level
|
||||
if body.proficiency is not None:
|
||||
row.proficiency = body.proficiency
|
||||
if body.quality_score is not None:
|
||||
row.quality_score = body.quality_score
|
||||
if body.efficiency_score is not None:
|
||||
row.efficiency_score = body.efficiency_score
|
||||
row.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return {"message": "Skill updated"}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}/skills/{skill_id}")
|
||||
async def unbind_user_skill(
|
||||
user_id: int,
|
||||
skill_id: int,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
row = (
|
||||
await db.execute(
|
||||
select(UserSkillBinding)
|
||||
.join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id)
|
||||
.where(
|
||||
and_(
|
||||
UserSkillBinding.tenant_id == tenant_id,
|
||||
UserSkillBinding.user_id == user_id,
|
||||
UserSkillBinding.skill_tag_id == skill_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Binding not found")
|
||||
if row.skill_tag and row.skill_tag.is_system:
|
||||
raise HTTPException(status_code=400, detail="System bound skill cannot be removed")
|
||||
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return {"message": "Skill unbound"}
|
||||
|
|
@ -56,7 +56,7 @@ async def list_skill_tags(
|
|||
async def create_skill_tag(
|
||||
body: SkillTagCreateRequest,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -80,7 +80,7 @@ async def update_skill_tag(
|
|||
tag_id: int,
|
||||
body: SkillTagUpdateRequest,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -109,7 +109,7 @@ async def update_skill_tag(
|
|||
async def delete_skill_tag(
|
||||
tag_id: int,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -164,7 +164,7 @@ async def bind_user_skill(
|
|||
user_id: int,
|
||||
body: UserSkillCreateRequest,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
@ -208,7 +208,7 @@ async def update_user_skill(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
current_role = str(current_user.role or "")
|
||||
is_manager = has_any_role(current_role, ["BOSS", "ADMIN"])
|
||||
is_manager = has_any_role(current_role, ["OWNER", "REVIEWER"])
|
||||
is_self = int(current_user.id) == user_id
|
||||
if not is_manager and not is_self:
|
||||
raise HTTPException(status_code=403, detail="No permission")
|
||||
|
|
@ -251,7 +251,7 @@ async def unbind_user_skill(
|
|||
user_id: int,
|
||||
skill_id: int,
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue