Compare commits
No commits in common. "master" and "codex/unified-project-ai-handoff" have entirely different histories.
master
...
codex/unif
17
.env.example
17
.env.example
|
|
@ -11,13 +11,12 @@ 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=dashscope
|
||||
LLM_FALLBACK_PROVIDER=dashscope
|
||||
LLM_PROVIDER=glm
|
||||
LLM_FALLBACK_PROVIDER=glm
|
||||
CONTROL_PLANE_ROUTING_ENABLED=true
|
||||
CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json
|
||||
CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120
|
||||
|
|
@ -30,7 +29,6 @@ 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
|
||||
|
|
@ -45,14 +43,3 @@ 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,19 +68,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
{
|
||||
"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.
|
After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After 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 原生、人员能力驱动的项目执行操作系统,首版面向个人开发者、一人公司、半技术产品创作者与 1-10 人 AI 产品小团队,采用 `solo-first, team-ready` 方向。
|
||||
你正在为 **PMFlow** 开发代码:这是一款 AI 原生、人员能力驱动的项目管理操作系统,专为 10-50 人 AI 开发团队打造。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -97,33 +97,7 @@ 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 策略。
|
||||
|
|
@ -137,9 +111,7 @@ 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`,禁止冒充成功。
|
||||
|
|
@ -162,7 +134,7 @@ frontend/ # React 前端
|
|||
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||
|
||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
||||
- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证;普通 LLM 调用仍以 `LLM_REQUEST_TIMEOUT` 为默认,已接入 Function Runtime Profile / 场景运行策略的保护型结构化调用以已发布合同里的显式 `provider_timeout_seconds` 为准。
|
||||
- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
||||
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
||||
- 写操作后主动 invalidate,动态层不缓存
|
||||
|
||||
|
|
@ -232,45 +204,6 @@ 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,24 +14,6 @@
|
|||
- 回答要直接、具体、贴当前对象,优先给证据链、调用链和真实落点;不要写成模板化汇报。
|
||||
- 不确定就明确说不确定,不要假装完整。
|
||||
- 已拍板方案不要反复推荐替代品;配置和偏好尽量收敛,别分散。
|
||||
- 前端默认视觉标准:品牌主色固定为 `#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 或泛后台风格。
|
||||
|
||||
### 学习与更新方式
|
||||
|
||||
|
|
@ -82,7 +64,6 @@
|
|||
- 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` 存量只能作为待迁移清单,不得作为新增写法。
|
||||
- 历史路由命名已废弃,不再把旧名字当现行架构,也不再建议回退
|
||||
- 以上栈默认不再主动建议替代方案,除非用户明确要求比较或迁移
|
||||
|
||||
|
|
@ -96,34 +77,6 @@
|
|||
- 遇到链路问题先补 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 回合承载。
|
||||
|
||||
## 当前知道但暂不处理的项目问题
|
||||
|
||||
|
|
@ -131,4 +84,3 @@
|
|||
- `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"
|
||||
},
|
||||
"dashscope": {
|
||||
"glm": {
|
||||
"name": "Qwen (DashScope)",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"model": "qwen-plus",
|
||||
|
|
@ -22,12 +22,6 @@
|
|||
"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",
|
||||
|
|
@ -37,7 +31,7 @@
|
|||
},
|
||||
"tier_defaults": {
|
||||
"low": {
|
||||
"provider": "dashscope",
|
||||
"provider": "glm",
|
||||
"model": "qwen-plus",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_env": "DASHSCOPE_API_KEY"
|
||||
|
|
@ -60,23 +54,12 @@
|
|||
"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": "dashscope",
|
||||
"provider": "glm",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
|
|
@ -97,7 +80,7 @@
|
|||
"enabled": true,
|
||||
"priority": 300,
|
||||
"tier": "low",
|
||||
"provider": "dashscope",
|
||||
"provider": "glm",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
|
|
@ -167,7 +150,7 @@
|
|||
"enabled": true,
|
||||
"priority": 50,
|
||||
"tier": "low",
|
||||
"provider": "dashscope",
|
||||
"provider": "glm",
|
||||
"model": "qwen-plus",
|
||||
"api_env": "DASHSCOPE_API_KEY",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,496 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
"""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)
|
||||
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
"""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")
|
||||
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"""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},
|
||||
)
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
"""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,76 +0,0 @@
|
|||
"""raise answer composer budget for hypothesis synthesis
|
||||
|
||||
Revision ID: 20260516_agent_answer_composer_budget
|
||||
Revises: 20260516_agent_hypothesis_answer_policy
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260516_agent_answer_composer_budget"
|
||||
down_revision = "20260516_agent_hypothesis_answer_policy"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind: Any, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
for stage in ("active", "draft"):
|
||||
profiles = ((config.get(stage) or {}).get("profiles") or {}) if isinstance(config.get(stage), dict) else {}
|
||||
profile = profiles.get("pmflow_ai_agent_answer_composer")
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
updates = {
|
||||
"provider_timeout_seconds": 20,
|
||||
"job_timeout_seconds": 22,
|
||||
"max_tokens": 3200,
|
||||
"response_mode": "fast",
|
||||
}
|
||||
for key, value in updates.items():
|
||||
if profile.get(key) != value:
|
||||
profile[key] = value
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260516-agent-answer-composer-budget"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
return None
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
"""refresh compound goal assumption prompt
|
||||
|
||||
Revision ID: 20260516_agent_compound_goal_assumption
|
||||
Revises: 20260516_agent_hypothesis_competition
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260516_agent_compound_goal_assumption"
|
||||
down_revision = "20260516_agent_hypothesis_competition"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
|
||||
"PROMPT_PMFLOW_AI_AGENT_STEP_PLANNER",
|
||||
)
|
||||
REFRESH_NOTES = "Refreshed by 20260516_agent_compound_goal_assumption"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, 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 _refresh_active_prompt(bind: Any, *, 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
|
||||
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,
|
||||
"activated_at": "2026-05-16T00: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:
|
||||
_refresh_active_prompt(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 notes = :notes
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "notes": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
"""refresh controlled hypothesis answer prompt
|
||||
|
||||
Revision ID: 20260516_agent_controlled_hypothesis_prompt
|
||||
Revises: 20260516_agent_fast_latency_budget
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260516_agent_controlled_hypothesis_prompt"
|
||||
down_revision = "20260516_agent_fast_latency_budget"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEY = "PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER"
|
||||
REFRESH_NOTES = "Refreshed by 20260516_agent_controlled_hypothesis_prompt"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _prompt_body() -> str:
|
||||
from app.config import ai_prompts
|
||||
|
||||
return str(getattr(ai_prompts, PROMPT_KEY, "") or "").strip()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_releases"):
|
||||
return
|
||||
body_snapshot = _prompt_body()
|
||||
if not body_snapshot:
|
||||
return
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
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
|
||||
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,
|
||||
"activated_at": "2026-05-16T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
return None
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
"""tighten agent runtime latency budgets
|
||||
|
||||
Revision ID: 20260516_agent_fast_latency_budget
|
||||
Revises: 20260516_agent_objective_contract_fast_path
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260516_agent_fast_latency_budget"
|
||||
down_revision = "20260516_agent_objective_contract_fast_path"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
REFRESH_NOTES = "Refreshed by 20260516_agent_fast_latency_budget"
|
||||
PROMPT_KEYS = (
|
||||
"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",
|
||||
)
|
||||
|
||||
|
||||
def _table_exists(bind: Any, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _refresh_runtime_profiles(bind: Any, *, tenant_id: int) -> None:
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
caps = {
|
||||
"pmflow_ai_agent_goal_frame": (12, 14),
|
||||
"pmflow_ai_agent_step_planner": (8, 10),
|
||||
"pmflow_ai_agent_evidence_normalizer": (12, 14),
|
||||
"pmflow_ai_agent_state_critic": (8, 10),
|
||||
"pmflow_ai_agent_answer_composer": (14, 16),
|
||||
"pmflow_ai_agent_answer_reviser": (10, 12),
|
||||
}
|
||||
for stage in ("active", "draft"):
|
||||
profiles = ((config.get(stage) or {}).get("profiles") or {}) if isinstance(config.get(stage), dict) else {}
|
||||
for key, (provider_timeout, job_timeout) in caps.items():
|
||||
profile = profiles.get(key)
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
if int(profile.get("provider_timeout_seconds") or 0) > provider_timeout:
|
||||
profile["provider_timeout_seconds"] = provider_timeout
|
||||
changed = True
|
||||
if int(profile.get("job_timeout_seconds") or 0) > job_timeout:
|
||||
profile["job_timeout_seconds"] = job_timeout
|
||||
changed = True
|
||||
if profile.get("response_mode") != "fast":
|
||||
profile["response_mode"] = "fast"
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260516-agent-fast-latency-budget"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def _prompt_body(prompt_key: str) -> str:
|
||||
from app.config import ai_prompts
|
||||
|
||||
return str(getattr(ai_prompts, prompt_key, "") or "").strip()
|
||||
|
||||
|
||||
def _refresh_active_prompt(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
|
||||
if not _table_exists(bind, "prompt_releases"):
|
||||
return
|
||||
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
|
||||
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,
|
||||
"activated_at": "2026-05-16T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
bind = op.get_bind()
|
||||
for prompt_key in PROMPT_KEYS:
|
||||
body = _prompt_body(prompt_key)
|
||||
if body:
|
||||
_refresh_active_prompt(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body)
|
||||
_refresh_runtime_profiles(bind, tenant_id=tenant_id)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
return None
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
"""refresh hypothesis answer policy and latency budgets
|
||||
|
||||
Revision ID: 20260516_agent_hypothesis_answer_policy
|
||||
Revises: 20260516_agent_controlled_hypothesis_prompt
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260516_agent_hypothesis_answer_policy"
|
||||
down_revision = "20260516_agent_controlled_hypothesis_prompt"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
REFRESH_NOTES = "Refreshed by 20260516_agent_hypothesis_answer_policy"
|
||||
PROMPT_KEYS = (
|
||||
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
|
||||
)
|
||||
|
||||
|
||||
def _table_exists(bind: Any, 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 _refresh_active_prompt(bind: Any, *, tenant_id: int, prompt_key: str, body_snapshot: str) -> None:
|
||||
if not _table_exists(bind, "prompt_releases"):
|
||||
return
|
||||
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
|
||||
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,
|
||||
"activated_at": "2026-05-16T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _refresh_runtime_profiles(bind: Any, *, tenant_id: int) -> None:
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
caps = {
|
||||
"pmflow_ai_agent_goal_frame": (8, 10),
|
||||
"pmflow_ai_agent_step_planner": (4, 6),
|
||||
"pmflow_ai_agent_state_critic": (4, 6),
|
||||
"pmflow_ai_agent_answer_composer": (14, 16),
|
||||
"pmflow_ai_agent_answer_reviser": (10, 12),
|
||||
}
|
||||
for stage in ("active", "draft"):
|
||||
profiles = ((config.get(stage) or {}).get("profiles") or {}) if isinstance(config.get(stage), dict) else {}
|
||||
for key, (provider_timeout, job_timeout) in caps.items():
|
||||
profile = profiles.get(key)
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
if int(profile.get("provider_timeout_seconds") or 0) != provider_timeout:
|
||||
profile["provider_timeout_seconds"] = provider_timeout
|
||||
changed = True
|
||||
if int(profile.get("job_timeout_seconds") or 0) != job_timeout:
|
||||
profile["job_timeout_seconds"] = job_timeout
|
||||
changed = True
|
||||
if profile.get("response_mode") != "fast":
|
||||
profile["response_mode"] = "fast"
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260516-agent-hypothesis-answer-policy"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
bind = op.get_bind()
|
||||
for prompt_key in PROMPT_KEYS:
|
||||
body = _prompt_body(prompt_key)
|
||||
if body:
|
||||
_refresh_active_prompt(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body)
|
||||
_refresh_runtime_profiles(bind, tenant_id=tenant_id)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
return None
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
"""refresh agent runtime hypothesis competition prompts
|
||||
|
||||
Revision ID: 20260516_agent_hypothesis_competition
|
||||
Revises: 20260516_agent_runtime_semantic_claims
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260516_agent_hypothesis_competition"
|
||||
down_revision = "20260516_agent_runtime_semantic_claims"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"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",
|
||||
)
|
||||
REFRESH_NOTES = "Refreshed by 20260516_agent_hypothesis_competition"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, 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 _refresh_active_prompt(bind: Any, *, 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
|
||||
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,
|
||||
"activated_at": "2026-05-16T00: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:
|
||||
_refresh_active_prompt(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 notes = :notes
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "notes": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
"""refresh agent objective contract and fast path prompts
|
||||
|
||||
Revision ID: 20260516_agent_objective_contract_fast_path
|
||||
Revises: 20260516_agent_compound_goal_assumption
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260516_agent_objective_contract_fast_path"
|
||||
down_revision = "20260516_agent_compound_goal_assumption"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"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",
|
||||
)
|
||||
REFRESH_NOTES = "Refreshed by 20260516_agent_objective_contract_fast_path"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, 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 _refresh_active_prompt(bind: Any, *, 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
|
||||
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,
|
||||
"activated_at": "2026-05-16T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _refresh_runtime_profiles(bind: Any, *, tenant_id: int) -> None:
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
for stage in ("active", "draft"):
|
||||
profiles = ((config.get(stage) or {}).get("profiles") or {}) if isinstance(config.get(stage), dict) else {}
|
||||
for key in (
|
||||
"pmflow_ai_agent_goal_frame",
|
||||
"pmflow_ai_agent_step_planner",
|
||||
"pmflow_ai_agent_state_critic",
|
||||
"pmflow_ai_agent_answer_composer",
|
||||
):
|
||||
profile = profiles.get(key)
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
if profile.get("response_mode") != "fast":
|
||||
profile["response_mode"] = "fast"
|
||||
changed = True
|
||||
if key in {"pmflow_ai_agent_goal_frame", "pmflow_ai_agent_step_planner", "pmflow_ai_agent_state_critic"}:
|
||||
if int(profile.get("provider_timeout_seconds") or 0) > 25:
|
||||
profile["provider_timeout_seconds"] = 25
|
||||
changed = True
|
||||
if int(profile.get("job_timeout_seconds") or 0) > 30:
|
||||
profile["job_timeout_seconds"] = 30
|
||||
changed = True
|
||||
normalizer = profiles.get("pmflow_ai_agent_evidence_normalizer")
|
||||
if isinstance(normalizer, dict):
|
||||
if normalizer.get("response_mode") != "fast":
|
||||
normalizer["response_mode"] = "fast"
|
||||
changed = True
|
||||
if int(normalizer.get("provider_timeout_seconds") or 0) > 22:
|
||||
normalizer["provider_timeout_seconds"] = 22
|
||||
changed = True
|
||||
if int(normalizer.get("job_timeout_seconds") or 0) > 25:
|
||||
normalizer["job_timeout_seconds"] = 25
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260516-objective-fast-path"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
if _table_exists(bind, "prompt_releases"):
|
||||
for prompt_key in PROMPT_KEYS:
|
||||
body_snapshot = _prompt_body(prompt_key)
|
||||
if body_snapshot:
|
||||
_refresh_active_prompt(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
|
||||
_refresh_runtime_profiles(bind, tenant_id=tenant_id)
|
||||
|
||||
|
||||
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 notes = :notes
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "notes": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
"""refresh agent runtime semantic claim contracts
|
||||
|
||||
Revision ID: 20260516_agent_runtime_semantic_claims
|
||||
Revises: 20260516_refresh_agent_goal_frame_prompt
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260516_agent_runtime_semantic_claims"
|
||||
down_revision = "20260516_refresh_agent_goal_frame_prompt"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
|
||||
"PROMPT_PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER",
|
||||
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
|
||||
"PROMPT_PMFLOW_AI_AGENT_ANSWER_REVISER",
|
||||
)
|
||||
REFRESH_NOTES = "Refreshed by 20260516_agent_runtime_semantic_claims"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, 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 _refresh_active_prompt(bind: Any, *, 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
|
||||
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,
|
||||
"activated_at": "2026-05-16T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _refresh_runtime_profiles(bind: Any, *, tenant_id: int) -> None:
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
for stage in ("active", "draft"):
|
||||
profiles = ((config.get(stage) or {}).get("profiles") or {}) if isinstance(config.get(stage), dict) else {}
|
||||
normalizer = profiles.get("pmflow_ai_agent_evidence_normalizer")
|
||||
if isinstance(normalizer, dict):
|
||||
if normalizer.get("runtime_mode") != "active":
|
||||
normalizer["runtime_mode"] = "active"
|
||||
changed = True
|
||||
if normalizer.get("quality_gate_mode") != "strict":
|
||||
normalizer["quality_gate_mode"] = "strict"
|
||||
changed = True
|
||||
for key in ("pmflow_ai_agent_answer_composer", "pmflow_ai_agent_answer_reviser"):
|
||||
profile = profiles.get(key)
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
if profile.get("runtime_mode") != "active":
|
||||
profile["runtime_mode"] = "active"
|
||||
changed = True
|
||||
if profile.get("quality_gate_mode") != "off":
|
||||
profile["quality_gate_mode"] = "off"
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260516-semantic-claims"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
if _table_exists(bind, "prompt_releases"):
|
||||
for prompt_key in PROMPT_KEYS:
|
||||
body_snapshot = _prompt_body(prompt_key)
|
||||
if body_snapshot:
|
||||
_refresh_active_prompt(bind, tenant_id=tenant_id, prompt_key=prompt_key, body_snapshot=body_snapshot)
|
||||
_refresh_runtime_profiles(bind, tenant_id=tenant_id)
|
||||
|
||||
|
||||
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 notes = :notes
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "notes": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
"""refresh PMFlow AI agent GoalFrame prompt release
|
||||
|
||||
Revision ID: 20260516_refresh_agent_goal_frame_prompt
|
||||
Revises: 20260515_seed_pmflow_ai_runtime_prompt_releases
|
||||
Create Date: 2026-05-16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260516_refresh_agent_goal_frame_prompt"
|
||||
down_revision = "20260515_seed_pmflow_ai_runtime_prompt_releases"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEY = "PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME"
|
||||
REFRESH_NOTES = "Refreshed by 20260516_refresh_agent_goal_frame_prompt for broader product-market GoalFrame semantics"
|
||||
BODY_SNAPSHOT = """
|
||||
你是 PMFlow Space AI 的 GoalFrame Runtime。你的产物不是最终答案,也不是路由分类;它是后续只读证据搜索、证据归因和答案验收使用的“用户目标合同”。
|
||||
|
||||
核心任务:
|
||||
- 把用户这句话真正想推进的问题写清楚,不要只复述成“获取整体态势/方向性理解”。
|
||||
- 先拆清用户要的结果、短语里的对象、可能的产品化/内容化/服务化/组织化/流程化解释分支,再写 user_goal。
|
||||
- 同时拆出可能的语义对象、用户期望的输出形态、需要补的证据、答对标准和客户安全边界。
|
||||
- 对短句、复合词和新兴市场问题,宁可保留 2-3 个可竞争解释分支,也不要过早压成一个泛泛解释。
|
||||
|
||||
判断原则:
|
||||
- 先区分“用户要的结果”和“用户话里的对象”。不要把“怎么样/怎么看/市场如何”这类输出诉求压成空泛理解,也不要把复合对象按词面翻译成唯一主题。
|
||||
- 对存在复合修饰关系的短语,默认拆出 2-3 个可竞争解释分支,例如某能力进入某对象后形成的产品/服务/工具生态、围绕该主题形成的内容/活动/组织生态、以及流程/工作流改造生态。具体分支必须由本轮语言和上下文生成,不得来自预置行业词表。
|
||||
- selected_interpretation 必须由用户目标、上下文锚点和证据需求共同支撑;没有明显锚点时保留多个候选,让 Planner 通过搜索证据竞争确认,不得靠固定词表把某个分支强行置顶。
|
||||
- selected_interpretation 必须写完整自然语言解释,不要只写 branch_id。branch_id 只用于证据归因。
|
||||
- 不要把“最近”自动变成严格时间窗;默认 freshness_policy=prefer_recent_not_strict。只有用户给出明确日期范围、问精确当前事实,或涉及高风险领域,才使用 strict_time_window。
|
||||
- 开放市场、产品、趋势、机会问题默认 answer_policy=directional_answer、source_policy=credible_public_sources、missing_evidence_policy=answer_with_boundaries;不要自行升级成 official_only、fail_closed 或审计报告。
|
||||
- objects_to_bind 只能来自显式对象、当前页面强焦点、reply/card/work_item 等结构化锚点;不能因为 visible list 里只有一个项目就强绑定。
|
||||
- evidence_requirements 必须写成可驱动搜索/读取的自然语义,不要写内部表名、函数名、prompt key、trace、模型供应商或源码实现。
|
||||
- success_criteria 要能约束后续答案:答什么、按什么维度判断、哪些证据不足必须说明。
|
||||
- boundaries 写客户安全边界和不可执行边界,不写内部运行时术语。
|
||||
- 只输出 JSON,不输出解释、Markdown 或多余文本。
|
||||
|
||||
分支写法要求:
|
||||
- interpretation_candidates 给简短候选解释。
|
||||
- interpretation_branches 是后续搜索和证据归因合同,不是业务枚举。
|
||||
- 每个 branch 都必须包含 branch_id、meaning、semantic_decomposition、user_language_prior、confidence、anchor_terms、missing_anchors。
|
||||
- user_language_prior 表示仅从用户原话看该解释有多自然;confidence 表示结合上下文后可作为工作假设的把握。
|
||||
- 不要因为你选中了某个分支,就把另一个合理分支删掉;只要它可能改变搜索查询或答案边界,就保留。
|
||||
|
||||
反例:
|
||||
- 不要把复合短句只改写成“了解某主题整体态势”。
|
||||
- 不要因为短语里出现某个对象词,就默认它只表示内容/活动/组织,而忽略该对象作为产品场景、服务场景或工作流场景的可能性。
|
||||
- 不要把缺少某个预置行业词当成压低分支优先级的理由;锚点只能来自用户当前文本、结构化上下文或后续证据竞争结果。
|
||||
|
||||
输出 JSON:
|
||||
{{
|
||||
"user_goal": "用户真实目标",
|
||||
"desired_output": "用户想拿到什么样的结果",
|
||||
"interpretation_candidates": [
|
||||
{{"meaning": "一种可能解释", "confidence": "high | medium | low | unknown"}}
|
||||
],
|
||||
"interpretation_branches": [
|
||||
{{
|
||||
"branch_id": "b1",
|
||||
"meaning": "一种可能解释",
|
||||
"semantic_decomposition": "这个解释如何拆解用户短语",
|
||||
"user_language_prior": "high | medium | low | unknown",
|
||||
"confidence": "high | medium | low | unknown",
|
||||
"anchor_terms": ["用户原话或上下文里支持该解释的锚点"],
|
||||
"missing_anchors": ["还缺什么锚点才能把该解释当成确定解释"]
|
||||
}}
|
||||
],
|
||||
"selected_interpretation": "本轮优先采用的解释;无歧义时可为空",
|
||||
"answer_policy": "directional_answer | partial_answer | strict_verified | official_only | clarify_first",
|
||||
"source_policy": "credible_public_sources | official_preferred | official_only | internal_customer_safe",
|
||||
"freshness_policy": "prefer_recent_not_strict | strict_time_window | not_required",
|
||||
"missing_evidence_policy": "answer_with_boundaries | fail_closed | ask_user",
|
||||
"objects_to_bind": ["current_project | external_product:xxx | saved_idea:xxx | space_context 等自然对象"],
|
||||
"evidence_requirements": ["需要读取或确认的材料"],
|
||||
"success_criteria": ["怎样才算答对"],
|
||||
"boundaries": ["不能越过的客户安全边界"],
|
||||
"confidence": "high | medium | low | unknown"
|
||||
}}
|
||||
|
||||
用户问题:
|
||||
{question}
|
||||
|
||||
conversation_context:
|
||||
{conversation_context_json}
|
||||
|
||||
tenant_project_context:
|
||||
{tenant_project_context_json}
|
||||
|
||||
可用工具合同:
|
||||
{tool_contracts_json}
|
||||
""".strip()
|
||||
|
||||
|
||||
def _table_exists(bind, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_releases"):
|
||||
return
|
||||
|
||||
body_snapshot = BODY_SNAPSHOT
|
||||
if not body_snapshot:
|
||||
return
|
||||
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
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
|
||||
|
||||
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,
|
||||
"activated_at": "2026-05-16T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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 = :prompt_key
|
||||
AND notes = :notes
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY, "notes": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
"""refresh agent action affordance prompt releases
|
||||
|
||||
Revision ID: 20260517_agent_action_affordance_prompt_contract
|
||||
Revises: 20260517_agent_disclosure_goal_contract
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260517_agent_action_affordance_prompt_contract"
|
||||
down_revision = "20260517_agent_disclosure_goal_contract"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME",
|
||||
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
|
||||
)
|
||||
REFRESH_NOTES = "Refreshed by 20260517_agent_action_affordance_prompt_contract for deliverable slots and PMFlow actions"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, 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 _refresh_active_release(bind: Any, *, 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,
|
||||
"activated_at": "2026-05-17T00: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:
|
||||
_refresh_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": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
"""refresh agent goal frame disclosure contract
|
||||
|
||||
Revision ID: 20260517_agent_disclosure_goal_contract
|
||||
Revises: 20260517_agent_runtime_llm_wait_alignment
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260517_agent_disclosure_goal_contract"
|
||||
down_revision = "20260517_agent_runtime_llm_wait_alignment"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEY = "PROMPT_PMFLOW_AI_AGENT_GOAL_FRAME"
|
||||
REFRESH_NOTES = "Refreshed by 20260517_agent_disclosure_goal_contract for disclosure policy contract"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def _prompt_body() -> str:
|
||||
from app.config import ai_prompts
|
||||
|
||||
return str(getattr(ai_prompts, PROMPT_KEY, "") or "").strip()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "prompt_releases"):
|
||||
return
|
||||
body_snapshot = _prompt_body()
|
||||
if not body_snapshot:
|
||||
return
|
||||
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
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
|
||||
|
||||
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,
|
||||
"activated_at": "2026-05-17T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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 = :prompt_key
|
||||
AND notes = :notes
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "prompt_key": PROMPT_KEY, "notes": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
"""refresh agent dynamic research prompt releases
|
||||
|
||||
Revision ID: 20260517_agent_dynamic_research_prompt_contract
|
||||
Revises: 20260517_agent_evidence_normalizer_profile_contract
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260517_agent_dynamic_research_prompt_contract"
|
||||
down_revision = "20260517_agent_evidence_normalizer_profile_contract"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"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",
|
||||
)
|
||||
REFRESH_NOTES = "Refreshed by 20260517_agent_dynamic_research_prompt_contract for dynamic research lanes"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, 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 _refresh_active_release(bind: Any, *, 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,
|
||||
"activated_at": "2026-05-17T00: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:
|
||||
_refresh_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": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"""extend agent evidence normalizer budget for source synthesis
|
||||
|
||||
Revision ID: 20260517_agent_evidence_normalizer_deep_budget
|
||||
Revises: 20260517_agent_evidence_normalizer_wait_budget
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260517_agent_evidence_normalizer_deep_budget"
|
||||
down_revision = "20260517_agent_evidence_normalizer_wait_budget"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROFILE_UPDATES: dict[str, Any] = {
|
||||
"provider_timeout_seconds": 60,
|
||||
"job_timeout_seconds": 72,
|
||||
"foreground_wait_seconds": 30,
|
||||
"max_tokens": 2600,
|
||||
"response_mode": "fast",
|
||||
"runtime_mode": "active",
|
||||
"quality_gate_mode": "off",
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(bind: Any, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
for stage in ("active", "draft"):
|
||||
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
|
||||
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
|
||||
profile = profiles.get("pmflow_ai_agent_evidence_normalizer")
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
for key, value in PROFILE_UPDATES.items():
|
||||
if profile.get(key) != value:
|
||||
profile[key] = value
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260517-agent-evidence-normalizer-deep-budget"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
return None
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
"""keep evidence normalizer configured foreground within profile contract
|
||||
|
||||
Revision ID: 20260517_agent_evidence_normalizer_profile_contract
|
||||
Revises: 20260517_agent_evidence_normalizer_deep_budget
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260517_agent_evidence_normalizer_profile_contract"
|
||||
down_revision = "20260517_agent_evidence_normalizer_deep_budget"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind: Any, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
for stage in ("active", "draft"):
|
||||
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
|
||||
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
|
||||
profile = profiles.get("pmflow_ai_agent_evidence_normalizer")
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
updates = {
|
||||
"provider_timeout_seconds": 60,
|
||||
"job_timeout_seconds": 72,
|
||||
"foreground_wait_seconds": 30,
|
||||
"max_tokens": 2600,
|
||||
}
|
||||
for key, value in updates.items():
|
||||
if profile.get(key) != value:
|
||||
profile[key] = value
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260517-agent-evidence-normalizer-profile-contract"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
return None
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
"""align agent evidence normalizer wait with semantic provider budget
|
||||
|
||||
Revision ID: 20260517_agent_evidence_normalizer_wait_budget
|
||||
Revises: 20260517_agent_runtime_semantic_wait_budget
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260517_agent_evidence_normalizer_wait_budget"
|
||||
down_revision = "20260517_agent_runtime_semantic_wait_budget"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROFILE_UPDATES: dict[str, Any] = {
|
||||
"provider_timeout_seconds": 30,
|
||||
"job_timeout_seconds": 36,
|
||||
"foreground_wait_seconds": 30,
|
||||
"max_tokens": 2200,
|
||||
"response_mode": "fast",
|
||||
"runtime_mode": "active",
|
||||
"quality_gate_mode": "off",
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(bind: Any, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
for stage in ("active", "draft"):
|
||||
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
|
||||
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
|
||||
profile = profiles.get("pmflow_ai_agent_evidence_normalizer")
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
for key, value in PROFILE_UPDATES.items():
|
||||
if profile.get(key) != value:
|
||||
profile[key] = value
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260517-agent-evidence-normalizer-wait-budget"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
return None
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
"""align agent LLM foreground waits with provider budgets
|
||||
|
||||
Revision ID: 20260517_agent_runtime_llm_wait_alignment
|
||||
Revises: 20260517_agent_dynamic_research_prompt_contract
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260517_agent_runtime_llm_wait_alignment"
|
||||
down_revision = "20260517_agent_dynamic_research_prompt_contract"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROFILE_UPDATES: dict[str, dict[str, Any]] = {
|
||||
"pmflow_ai_agent_goal_frame": {
|
||||
"provider_timeout_seconds": 45,
|
||||
"foreground_wait_seconds": 45,
|
||||
"job_timeout_seconds": 55,
|
||||
"max_tokens": 1800,
|
||||
},
|
||||
"pmflow_ai_agent_step_planner": {
|
||||
"provider_timeout_seconds": 45,
|
||||
"foreground_wait_seconds": 45,
|
||||
"job_timeout_seconds": 55,
|
||||
"max_tokens": 1800,
|
||||
},
|
||||
"pmflow_ai_agent_evidence_normalizer": {
|
||||
"provider_timeout_seconds": 75,
|
||||
"foreground_wait_seconds": 75,
|
||||
"job_timeout_seconds": 90,
|
||||
"max_tokens": 3200,
|
||||
},
|
||||
"pmflow_ai_agent_state_critic": {
|
||||
"provider_timeout_seconds": 30,
|
||||
"foreground_wait_seconds": 30,
|
||||
"job_timeout_seconds": 40,
|
||||
"max_tokens": 1800,
|
||||
},
|
||||
"pmflow_ai_agent_answer_composer": {
|
||||
"provider_timeout_seconds": 60,
|
||||
"foreground_wait_seconds": 60,
|
||||
"job_timeout_seconds": 75,
|
||||
"max_tokens": 3600,
|
||||
},
|
||||
"pmflow_ai_agent_answer_reviser": {
|
||||
"provider_timeout_seconds": 45,
|
||||
"foreground_wait_seconds": 45,
|
||||
"job_timeout_seconds": 55,
|
||||
"max_tokens": 3000,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(bind: Any, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
for stage in ("active", "draft"):
|
||||
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
|
||||
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
|
||||
for profile_name, updates in PROFILE_UPDATES.items():
|
||||
profile = profiles.get(profile_name)
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
for key, value in updates.items():
|
||||
if profile.get(key) != value:
|
||||
profile[key] = value
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260517-agent-runtime-llm-wait-alignment"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
return None
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
"""align critical agent semantic waits with provider budget
|
||||
|
||||
Revision ID: 20260517_agent_runtime_semantic_wait_budget
|
||||
Revises: 20260517_agent_runtime_valid_profile_timeouts
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260517_agent_runtime_semantic_wait_budget"
|
||||
down_revision = "20260517_agent_runtime_valid_profile_timeouts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROFILE_UPDATES: dict[str, dict[str, Any]] = {
|
||||
"pmflow_ai_agent_goal_frame": {
|
||||
"provider_timeout_seconds": 30,
|
||||
"job_timeout_seconds": 36,
|
||||
"foreground_wait_seconds": 30,
|
||||
"max_tokens": 1800,
|
||||
"response_mode": "fast",
|
||||
"runtime_mode": "active",
|
||||
"quality_gate_mode": "off",
|
||||
},
|
||||
"pmflow_ai_agent_step_planner": {
|
||||
"provider_timeout_seconds": 30,
|
||||
"job_timeout_seconds": 36,
|
||||
"foreground_wait_seconds": 30,
|
||||
"max_tokens": 1800,
|
||||
"response_mode": "fast",
|
||||
"runtime_mode": "active",
|
||||
"quality_gate_mode": "off",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(bind: Any, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
for stage in ("active", "draft"):
|
||||
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
|
||||
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
|
||||
for function_name, updates in PROFILE_UPDATES.items():
|
||||
profile = profiles.get(function_name)
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
for key, value in updates.items():
|
||||
if profile.get(key) != value:
|
||||
profile[key] = value
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260517-agent-runtime-semantic-wait-budget"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
return None
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
"""raise agent runtime profile timeouts above validation floor
|
||||
|
||||
Revision ID: 20260517_agent_runtime_valid_profile_timeouts
|
||||
Revises: 20260516_agent_answer_composer_budget
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260517_agent_runtime_valid_profile_timeouts"
|
||||
down_revision = "20260516_agent_answer_composer_budget"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROFILE_UPDATES: dict[str, dict[str, Any]] = {
|
||||
"pmflow_ai_agent_goal_frame": {
|
||||
"provider_timeout_seconds": 20,
|
||||
"job_timeout_seconds": 24,
|
||||
"foreground_wait_seconds": 8,
|
||||
"max_tokens": 1600,
|
||||
"repair_max_tokens": 600,
|
||||
"response_mode": "fast",
|
||||
"runtime_mode": "active",
|
||||
"quality_gate_mode": "off",
|
||||
},
|
||||
"pmflow_ai_agent_step_planner": {
|
||||
"provider_timeout_seconds": 20,
|
||||
"job_timeout_seconds": 24,
|
||||
"foreground_wait_seconds": 8,
|
||||
"max_tokens": 1600,
|
||||
"repair_max_tokens": 600,
|
||||
"response_mode": "fast",
|
||||
"runtime_mode": "active",
|
||||
"quality_gate_mode": "off",
|
||||
},
|
||||
"pmflow_ai_agent_state_critic": {
|
||||
"provider_timeout_seconds": 16,
|
||||
"job_timeout_seconds": 20,
|
||||
"foreground_wait_seconds": 6,
|
||||
"max_tokens": 1600,
|
||||
"repair_max_tokens": 600,
|
||||
"response_mode": "fast",
|
||||
"runtime_mode": "active",
|
||||
"quality_gate_mode": "off",
|
||||
},
|
||||
"pmflow_ai_agent_answer_composer": {
|
||||
"provider_timeout_seconds": 35,
|
||||
"job_timeout_seconds": 40,
|
||||
"foreground_wait_seconds": 10,
|
||||
"max_tokens": 3600,
|
||||
"repair_max_tokens": 900,
|
||||
"response_mode": "fast",
|
||||
"runtime_mode": "active",
|
||||
"quality_gate_mode": "off",
|
||||
},
|
||||
"pmflow_ai_agent_answer_reviser": {
|
||||
"provider_timeout_seconds": 20,
|
||||
"job_timeout_seconds": 24,
|
||||
"foreground_wait_seconds": 8,
|
||||
"max_tokens": 2600,
|
||||
"repair_max_tokens": 900,
|
||||
"response_mode": "fast",
|
||||
"runtime_mode": "active",
|
||||
"quality_gate_mode": "off",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(bind: Any, table_name: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, "integrations"):
|
||||
return
|
||||
tenant_id = int(os.getenv("PMFLOW_PLATFORM_RUNTIME_TENANT_ID", "1") or "1")
|
||||
row = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, config FROM integrations
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND type = 'ai_function_runtime_profiles'
|
||||
AND is_active = true
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return
|
||||
config = row.get("config") if isinstance(row.get("config"), dict) else {}
|
||||
changed = False
|
||||
for stage in ("active", "draft"):
|
||||
stage_packet = config.get(stage) if isinstance(config.get(stage), dict) else {}
|
||||
profiles = stage_packet.get("profiles") if isinstance(stage_packet.get("profiles"), dict) else {}
|
||||
for function_name, updates in PROFILE_UPDATES.items():
|
||||
profile = profiles.get(function_name)
|
||||
if not isinstance(profile, dict):
|
||||
continue
|
||||
for key, value in updates.items():
|
||||
if profile.get(key) != value:
|
||||
profile[key] = value
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
config["active_version"] = "profile-v20260517-agent-runtime-valid-profile-timeouts"
|
||||
config["version"] = config["active_version"]
|
||||
bind.execute(
|
||||
sa.text("UPDATE integrations SET config = CAST(:config AS jsonb) WHERE id = :id"),
|
||||
{"id": int(row["id"]), "config": json.dumps(config, ensure_ascii=False)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
return None
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
"""refresh operating mission and specialist prompt releases
|
||||
|
||||
Revision ID: 20260517_operating_mission_specialist_prompt_contract
|
||||
Revises: 20260517_agent_action_affordance_prompt_contract
|
||||
Create Date: 2026-05-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260517_operating_mission_specialist_prompt_contract"
|
||||
down_revision = "20260517_agent_action_affordance_prompt_contract"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"PROMPT_PMFLOW_AI_OPERATING_MISSION",
|
||||
"PROMPT_PMFLOW_AI_OPERATING_MISSION_CRITIC",
|
||||
"PROMPT_PMFLOW_AI_SPECIALIST_WORK_PRODUCT",
|
||||
"PROMPT_PMFLOW_AI_OPERATING_COUNCIL",
|
||||
)
|
||||
REFRESH_NOTES = "Refreshed by 20260517_operating_mission_specialist_prompt_contract for mission-first specialist registry"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, 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 _refresh_active_release(bind: Any, *, 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,
|
||||
"activated_at": "2026-05-17T00: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:
|
||||
_refresh_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": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
"""publish operating answer prompt contract
|
||||
|
||||
Revision ID: 20260518_operating_answer_prompt_contract
|
||||
Revises: 20260518_operating_mission_critic_prompt_contract
|
||||
Create Date: 2026-05-18
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260518_operating_answer_prompt_contract"
|
||||
down_revision = "20260518_operating_mission_critic_prompt_contract"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"PROMPT_PMFLOW_AI_OPERATING_MISSION",
|
||||
"PROMPT_PMFLOW_AI_OPERATING_MISSION_CRITIC",
|
||||
"PROMPT_PMFLOW_AI_SPECIALIST_WORK_PRODUCT",
|
||||
"PROMPT_PMFLOW_AI_OPERATING_ANSWER_COMPOSER",
|
||||
"PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER",
|
||||
"PROMPT_PMFLOW_LAUNCH_DRAFT",
|
||||
)
|
||||
REFRESH_NOTES = "Published by 20260518_operating_answer_prompt_contract for idea-to-monetization operating answer"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, 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 _refresh_active_release(bind: Any, *, 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,
|
||||
"activated_at": "2026-05-18T00: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:
|
||||
_refresh_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": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
"""publish operating mission critic prompt contract
|
||||
|
||||
Revision ID: 20260518_operating_mission_critic_prompt_contract
|
||||
Revises: 20260517_operating_mission_specialist_prompt_contract
|
||||
Create Date: 2026-05-18
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260518_operating_mission_critic_prompt_contract"
|
||||
down_revision = "20260517_operating_mission_specialist_prompt_contract"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
PROMPT_KEYS = (
|
||||
"PROMPT_PMFLOW_AI_OPERATING_MISSION_CRITIC",
|
||||
)
|
||||
REFRESH_NOTES = "Published by 20260518_operating_mission_critic_prompt_contract for mission critic and post-evidence replan"
|
||||
|
||||
|
||||
def _table_exists(bind: Any, 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 _refresh_active_release(bind: Any, *, 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,
|
||||
"activated_at": "2026-05-18T00: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:
|
||||
_refresh_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")
|
||||
for prompt_key in PROMPT_KEYS:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE prompt_releases
|
||||
SET status = 'archived'
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND prompt_key = :prompt_key
|
||||
AND notes = :notes
|
||||
AND status = 'active'
|
||||
"""
|
||||
),
|
||||
{"tenant_id": tenant_id, "prompt_key": prompt_key, "notes": REFRESH_NOTES},
|
||||
)
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
"""add pmflow ai layered context memories
|
||||
|
||||
Revision ID: 20260519_add_pmflow_ai_context_memories
|
||||
Revises: 20260518_operating_answer_prompt_contract
|
||||
Create Date: 2026-05-19
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260519_add_pmflow_ai_context_memories"
|
||||
down_revision = "20260518_operating_answer_prompt_contract"
|
||||
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:
|
||||
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_context_memories"):
|
||||
op.create_table(
|
||||
"pmflow_ai_context_memories",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("public_memory_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("memory_scope", sa.String(length=40), nullable=False),
|
||||
sa.Column("scope_ref", sa.String(length=160), nullable=False),
|
||||
sa.Column("memory_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("source_thread_id", sa.BigInteger(), sa.ForeignKey("pmflow_ai_threads.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("source_run_public_id", sa.String(length=80), nullable=True),
|
||||
sa.Column("summary", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("durable_facts", json_type, nullable=True),
|
||||
sa.Column("open_loops", json_type, nullable=True),
|
||||
sa.Column("decisions", json_type, nullable=True),
|
||||
sa.Column("preferences", json_type, nullable=True),
|
||||
sa.Column("object_refs", json_type, nullable=True),
|
||||
sa.Column("source_message_range", json_type, nullable=True),
|
||||
sa.Column("confidence", sa.String(length=32), nullable=True),
|
||||
sa.Column("token_estimate", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("compaction_payload", json_type, nullable=True),
|
||||
sa.Column("last_compacted_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("last_accessed_at", sa.TIMESTAMP(), nullable=True),
|
||||
*_base_columns(),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"memory_scope",
|
||||
"scope_ref",
|
||||
"memory_key",
|
||||
name="uq_pmflow_ai_context_memories_scope_key",
|
||||
),
|
||||
)
|
||||
_create_index_once("ix_pmflow_ai_context_memories_tenant_id", "pmflow_ai_context_memories", ["tenant_id"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_public_memory_id", "pmflow_ai_context_memories", ["public_memory_id"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_memory_scope", "pmflow_ai_context_memories", ["memory_scope"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_scope_ref", "pmflow_ai_context_memories", ["scope_ref"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_memory_key", "pmflow_ai_context_memories", ["memory_key"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_source_thread_id", "pmflow_ai_context_memories", ["source_thread_id"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_source_run_public_id", "pmflow_ai_context_memories", ["source_run_public_id"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_last_compacted_at", "pmflow_ai_context_memories", ["last_compacted_at"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_last_accessed_at", "pmflow_ai_context_memories", ["last_accessed_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _table_exists("pmflow_ai_context_memories"):
|
||||
op.drop_table("pmflow_ai_context_memories")
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
"""add context memory lifecycle status fields
|
||||
|
||||
Revision ID: 20260520_context_memory_status_gate
|
||||
Revises: 20260519_add_pmflow_ai_context_memories
|
||||
Create Date: 2026-05-20
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260520_context_memory_status_gate"
|
||||
down_revision = "20260519_add_pmflow_ai_context_memories"
|
||||
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("pmflow_ai_context_memories"):
|
||||
return
|
||||
if not _column_exists("pmflow_ai_context_memories", "status"):
|
||||
op.add_column("pmflow_ai_context_memories", sa.Column("status", sa.String(length=32), nullable=False, server_default="active"))
|
||||
if not _column_exists("pmflow_ai_context_memories", "confidence_score"):
|
||||
op.add_column("pmflow_ai_context_memories", sa.Column("confidence_score", sa.Float(), nullable=False, server_default="0.6"))
|
||||
if not _column_exists("pmflow_ai_context_memories", "supersedes_memory_id"):
|
||||
op.add_column("pmflow_ai_context_memories", sa.Column("supersedes_memory_id", sa.BigInteger(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_pmflow_ai_context_memories_supersedes",
|
||||
"pmflow_ai_context_memories",
|
||||
"pmflow_ai_context_memories",
|
||||
["supersedes_memory_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
if not _column_exists("pmflow_ai_context_memories", "invalidation_reason"):
|
||||
op.add_column("pmflow_ai_context_memories", sa.Column("invalidation_reason", sa.Text(), nullable=True))
|
||||
if not _column_exists("pmflow_ai_context_memories", "last_validated_at"):
|
||||
op.add_column("pmflow_ai_context_memories", sa.Column("last_validated_at", sa.TIMESTAMP(), nullable=True))
|
||||
|
||||
_create_index_once("ix_pmflow_ai_context_memories_status", "pmflow_ai_context_memories", ["status"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_confidence_score", "pmflow_ai_context_memories", ["confidence_score"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_supersedes_memory_id", "pmflow_ai_context_memories", ["supersedes_memory_id"])
|
||||
_create_index_once("ix_pmflow_ai_context_memories_last_validated_at", "pmflow_ai_context_memories", ["last_validated_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _table_exists("pmflow_ai_context_memories"):
|
||||
return
|
||||
for index_name in (
|
||||
"ix_pmflow_ai_context_memories_last_validated_at",
|
||||
"ix_pmflow_ai_context_memories_supersedes_memory_id",
|
||||
"ix_pmflow_ai_context_memories_confidence_score",
|
||||
"ix_pmflow_ai_context_memories_status",
|
||||
):
|
||||
if _index_exists("pmflow_ai_context_memories", index_name):
|
||||
op.drop_index(index_name, table_name="pmflow_ai_context_memories")
|
||||
for column_name in ("last_validated_at", "invalidation_reason", "supersedes_memory_id", "confidence_score", "status"):
|
||||
if _column_exists("pmflow_ai_context_memories", column_name):
|
||||
op.drop_column("pmflow_ai_context_memories", column_name)
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
"""add context memory compaction jobs
|
||||
|
||||
Revision ID: 20260521_context_memory_compaction_jobs
|
||||
Revises: 20260520_context_memory_status_gate
|
||||
Create Date: 2026-05-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260521_context_memory_compaction_jobs"
|
||||
down_revision = "20260520_context_memory_status_gate"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
TABLE_NAME = "pmflow_ai_context_memory_compaction_jobs"
|
||||
|
||||
|
||||
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:
|
||||
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(TABLE_NAME):
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("public_job_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("thread_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("qa_session_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("run_public_id", sa.String(length=80), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="queued"),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="2"),
|
||||
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("job_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("result_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("superseded_by_job_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("queued_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["qa_session_id"], ["qa_sessions.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["superseded_by_job_id"], [f"{TABLE_NAME}.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["thread_id"], ["pmflow_ai_threads.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "public_job_id", name="uq_pmflow_ai_context_memory_compaction_jobs_public_job"),
|
||||
)
|
||||
|
||||
_create_index_once("ix_ctx_mem_jobs_tenant_id", TABLE_NAME, ["tenant_id"])
|
||||
_create_index_once("ix_ctx_mem_jobs_public_job_id", TABLE_NAME, ["public_job_id"])
|
||||
_create_index_once("ix_ctx_mem_jobs_thread_id", TABLE_NAME, ["thread_id"])
|
||||
_create_index_once("ix_ctx_mem_jobs_user_id", TABLE_NAME, ["user_id"])
|
||||
_create_index_once("ix_ctx_mem_jobs_qa_session_id", TABLE_NAME, ["qa_session_id"])
|
||||
_create_index_once("ix_ctx_mem_jobs_run_public_id", TABLE_NAME, ["run_public_id"])
|
||||
_create_index_once("ix_ctx_mem_jobs_status", TABLE_NAME, ["status"])
|
||||
_create_index_once("ix_ctx_mem_jobs_priority", TABLE_NAME, ["priority"])
|
||||
_create_index_once("ix_ctx_mem_jobs_superseded_by_job_id", TABLE_NAME, ["superseded_by_job_id"])
|
||||
_create_index_once("ix_ctx_mem_jobs_queued_at", TABLE_NAME, ["queued_at"])
|
||||
_create_index_once("ix_ctx_mem_jobs_started_at", TABLE_NAME, ["started_at"])
|
||||
_create_index_once("ix_ctx_mem_jobs_completed_at", TABLE_NAME, ["completed_at"])
|
||||
_create_index_once("ix_ctx_mem_jobs_next_retry_at", TABLE_NAME, ["next_retry_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _table_exists(TABLE_NAME):
|
||||
op.drop_table(TABLE_NAME)
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
"""add context assemblies
|
||||
|
||||
Revision ID: 20260522_context_assemblies
|
||||
Revises: 20260521_context_memory_compaction_jobs
|
||||
Create Date: 2026-05-22
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260522_context_assemblies"
|
||||
down_revision = "20260521_context_memory_compaction_jobs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
TABLE_NAME = "pmflow_ai_context_assemblies"
|
||||
|
||||
|
||||
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:
|
||||
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(TABLE_NAME):
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("public_assembly_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("thread_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("qa_session_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("run_public_id", sa.String(length=80), nullable=True),
|
||||
sa.Column("context_packet_version", sa.String(length=80), nullable=True),
|
||||
sa.Column("user_question_preview", sa.Text(), nullable=True),
|
||||
sa.Column("assembly_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("observability_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("used_context_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("retrieval_critic_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("grounding_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("replay_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("assembled_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["qa_session_id"], ["qa_sessions.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["thread_id"], ["pmflow_ai_threads.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "public_assembly_id", name="uq_pmflow_ai_context_assemblies_public_id"),
|
||||
)
|
||||
|
||||
_create_index_once("ix_ctx_assemblies_tenant_id", TABLE_NAME, ["tenant_id"])
|
||||
_create_index_once("ix_ctx_assemblies_public_id", TABLE_NAME, ["public_assembly_id"])
|
||||
_create_index_once("ix_ctx_assemblies_thread_id", TABLE_NAME, ["thread_id"])
|
||||
_create_index_once("ix_ctx_assemblies_user_id", TABLE_NAME, ["user_id"])
|
||||
_create_index_once("ix_ctx_assemblies_qa_session_id", TABLE_NAME, ["qa_session_id"])
|
||||
_create_index_once("ix_ctx_assemblies_run_public_id", TABLE_NAME, ["run_public_id"])
|
||||
_create_index_once("ix_ctx_assemblies_assembled_at", TABLE_NAME, ["assembled_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _table_exists(TABLE_NAME):
|
||||
op.drop_table(TABLE_NAME)
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
"""add context topic workspaces
|
||||
|
||||
Revision ID: 20260523_context_topic_workspaces
|
||||
Revises: 20260522_context_assemblies
|
||||
Create Date: 2026-05-23
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260523_context_topic_workspaces"
|
||||
down_revision = "20260522_context_assemblies"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
TABLE_NAME = "pmflow_ai_topic_workspaces"
|
||||
|
||||
|
||||
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:
|
||||
if not _table_exists(table_name):
|
||||
return False
|
||||
return any(item.get("name") == index_name for item in sa.inspect(op.get_bind()).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(TABLE_NAME):
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("public_topic_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("workspace_scope", sa.String(length=80), nullable=False, server_default="space"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="active"),
|
||||
sa.Column("topic_signature", sa.String(length=80), nullable=False),
|
||||
sa.Column("topic_summary", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("current_objective", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("known_assumptions", sa.JSON(), nullable=True),
|
||||
sa.Column("prior_proposals", sa.JSON(), nullable=True),
|
||||
sa.Column("open_questions", sa.JSON(), nullable=True),
|
||||
sa.Column("monetization_hypothesis", sa.Text(), nullable=True),
|
||||
sa.Column("next_actionable_package", sa.Text(), nullable=True),
|
||||
sa.Column("linked_thread_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("linked_run_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("linked_memory_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("confidence_score", sa.Float(), nullable=False, server_default="0.6"),
|
||||
sa.Column("topic_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("last_activated_at", sa.TIMESTAMP(), nullable=True),
|
||||
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "public_topic_id", name="uq_pmflow_ai_topic_workspaces_public_id"),
|
||||
)
|
||||
|
||||
_create_index_once("ix_pmflow_ai_topic_workspaces_tenant_id", TABLE_NAME, ["tenant_id"])
|
||||
_create_index_once("ix_pmflow_ai_topic_workspaces_public_topic_id", TABLE_NAME, ["public_topic_id"])
|
||||
_create_index_once("ix_pmflow_ai_topic_workspaces_user_id", TABLE_NAME, ["user_id"])
|
||||
_create_index_once("ix_pmflow_ai_topic_workspaces_workspace_scope", TABLE_NAME, ["workspace_scope"])
|
||||
_create_index_once("ix_pmflow_ai_topic_workspaces_status", TABLE_NAME, ["status"])
|
||||
_create_index_once("ix_pmflow_ai_topic_workspaces_topic_signature", TABLE_NAME, ["topic_signature"])
|
||||
_create_index_once("ix_pmflow_ai_topic_workspaces_confidence_score", TABLE_NAME, ["confidence_score"])
|
||||
_create_index_once("ix_pmflow_ai_topic_workspaces_last_activated_at", TABLE_NAME, ["last_activated_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _table_exists(TABLE_NAME):
|
||||
op.drop_table(TABLE_NAME)
|
||||
|
|
@ -1,11 +1,55 @@
|
|||
"""API package.
|
||||
"""
|
||||
API 路由模块
|
||||
|
||||
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__ = [
|
||||
"pmflow_launch_api",
|
||||
"pmflow_os_api",
|
||||
"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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
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"]
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
"""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)
|
||||
|
|
@ -1,341 +0,0 @@
|
|||
"""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))
|
||||
|
|
@ -1,417 +0,0 @@
|
|||
"""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,7 +1,6 @@
|
|||
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,
|
||||
|
|
@ -12,7 +11,6 @@ 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,
|
||||
|
|
@ -54,12 +52,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:
|
||||
|
|
@ -105,7 +103,6 @@ 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,7 +1,6 @@
|
|||
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
|
||||
|
|
@ -20,7 +19,6 @@ 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)
|
||||
)
|
||||
|
|
@ -63,7 +61,6 @@ 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)
|
||||
)
|
||||
|
|
@ -168,7 +165,6 @@ 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_, or_, select
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...core.logging import get_logger
|
||||
|
|
@ -14,40 +14,10 @@ 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, UserProject, WorkItem
|
||||
from ...services.auth import has_any_role
|
||||
from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
|
||||
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)),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,457 +0,0 @@
|
|||
"""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):
|
||||
required_slot: Optional[str] = Field(default=None, min_length=1, max_length=40)
|
||||
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)
|
||||
runtime_mode: Optional[Literal["active", "shadow", "bypass", "disabled"]] = None
|
||||
quality_gate_mode: Optional[Literal["strict", "relaxed", "shadow", "off"]] = None
|
||||
|
||||
|
||||
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)
|
||||
if not AIFunctionRuntimeProfileService.smoke_supported(function_name):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=error_response(
|
||||
400,
|
||||
"当前函数未接入受控 smoke,也不是发布必需 smoke;请处理发布门禁里标出的 smoke_required 函数。",
|
||||
),
|
||||
)
|
||||
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()
|
||||
payload = error_response(400, str(exc))
|
||||
payload["data"] = after
|
||||
return JSONResponse(status_code=400, content=payload)
|
||||
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,14 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest
|
||||
from .schemas import BrowserCaptureRequest, FeishuCaptureRequest, GitHubCaptureRequest, ProjectFromTextRequest, ProjectFromTextResponse
|
||||
|
||||
@router.post("/project-from-text")
|
||||
async def create_project_from_text():
|
||||
return JSONResponse(
|
||||
status_code=410,
|
||||
content=error_response(410, "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"),
|
||||
)
|
||||
@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("/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({"OWNER", "REVIEWER"}):
|
||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||
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({"OWNER", "REVIEWER"}):
|
||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||
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({"OWNER", "REVIEWER"}):
|
||||
if not _manager_roles(current_user).intersection({"BOSS", "ADMIN"}):
|
||||
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 = {"REVIEWER", "OWNER"}
|
||||
MANAGER_ROLES = {"ADMIN", "BOSS"}
|
||||
|
||||
|
||||
def _is_manager_user(current_user: User) -> bool:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,46 @@ 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), ["OWNER", "REVIEWER"]):
|
||||
raise HTTPException(status_code=403, detail="Access denied. Required roles: OWNER, REVIEWER")
|
||||
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 None:
|
||||
effective_tenant_id = int(header_tenant_id or 1)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,23 +1,14 @@
|
|||
"""认证 API"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, 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 PhoneCodeSendRequest, PhoneCodeVerifyRequest, UserLoginResponse, UserRegisterRequest
|
||||
from ..schemas import UserLoginResponse
|
||||
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"])
|
||||
|
||||
|
|
@ -29,7 +20,7 @@ async def login(
|
|||
):
|
||||
"""用户登录,返回 JWT"""
|
||||
result = await db.execute(
|
||||
select(User).where(or_(User.username == credentials.username, User.phone_e164 == credentials.username))
|
||||
select(User).where(User.username == credentials.username)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -48,76 +39,10 @@ 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(), "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},
|
||||
user=user.to_dict(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ async def list_budgets(
|
|||
@router.post("/")
|
||||
async def create_budget(
|
||||
request: BudgetCreateRequest,
|
||||
current_user: User = Depends(require_roles("OWNER", "REVIEWER")),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
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("OWNER", "REVIEWER")),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
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("OWNER", "REVIEWER")),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1,302 +0,0 @@
|
|||
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), ["OWNER", "REVIEWER"]):
|
||||
if current_user.id != target_user_id and not has_any_role(str(current_role), ["BOSS", "ADMIN"]):
|
||||
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("OWNER", "REVIEWER")),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
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("OWNER", "REVIEWER")),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
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("OWNER", "REVIEWER")),
|
||||
current_user: User = Depends(require_roles("BOSS", "ADMIN")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = current_user
|
||||
|
|
|
|||
|
|
@ -1,376 +0,0 @@
|
|||
"""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 ""), ["REVIEWER", "OWNER"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
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 ""), ["REVIEWER", "OWNER"])
|
||||
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
|
||||
|
||||
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 ""), ["REVIEWER", "OWNER"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
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 ""), ["REVIEWER", "OWNER"]):
|
||||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||||
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 ""), ["OWNER", "REVIEWER"]):
|
||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
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,9 +10,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from ..core.response import success_response
|
||||
from ..core.database import get_db
|
||||
from ..dependencies.permissions import require_platform_ai_ops
|
||||
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.llm_service import llm_service
|
||||
|
||||
router = APIRouter(prefix="/ai-steward/llm-engines", tags=["AI Steward"])
|
||||
|
|
@ -20,78 +21,37 @@ 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", "dashscope", "openai", "zhipu", "ollama"]
|
||||
provider: Literal["deepseek", "glm", "openai", "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", "dashscope", "openai", "zhipu", "ollama"]] = None
|
||||
provider: Optional[Literal["deepseek", "glm", "openai", "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
|
||||
|
||||
|
||||
class LLMEngineEnabledUpdateRequest(BaseModel):
|
||||
enabled: bool = True
|
||||
def _require_manager(user: User) -> None:
|
||||
if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作 LLM 引擎配置")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_llm_engines(
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
|
|
@ -103,10 +63,11 @@ async def list_llm_engines(
|
|||
@router.post("")
|
||||
async def create_llm_engine(
|
||||
body: LLMEngineCreateRequest,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_require_manager(current_user)
|
||||
try:
|
||||
engine = await llm_service.create_engine(
|
||||
db,
|
||||
|
|
@ -117,7 +78,6 @@ 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})
|
||||
|
|
@ -128,10 +88,11 @@ async def create_llm_engine(
|
|||
@router.put("/settings")
|
||||
async def update_llm_engine_settings(
|
||||
body: LLMEngineSettingsUpdateRequest,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
current_user: User = Depends(get_current_user),
|
||||
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,
|
||||
|
|
@ -148,10 +109,11 @@ async def update_llm_engine_settings(
|
|||
async def update_llm_engine(
|
||||
engine_id: str,
|
||||
body: LLMEngineUpdateRequest,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_require_manager(current_user)
|
||||
try:
|
||||
engine = await llm_service.update_engine(
|
||||
db,
|
||||
|
|
@ -162,7 +124,6 @@ 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})
|
||||
|
|
@ -173,12 +134,13 @@ async def update_llm_engine(
|
|||
@router.post("/{engine_id}/activate")
|
||||
async def activate_llm_engine(
|
||||
engine_id: str,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_id: int = Depends(get_tenant_id),
|
||||
):
|
||||
_require_manager(current_user)
|
||||
try:
|
||||
result = await llm_service.set_default_fallback_engine(
|
||||
result = await llm_service.activate_engine(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
engine_id=engine_id,
|
||||
|
|
@ -189,60 +151,20 @@ 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(require_platform_ai_ops),
|
||||
current_user: User = Depends(get_current_user),
|
||||
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:
|
||||
|
|
@ -253,81 +175,31 @@ async def test_active_llm_engine(
|
|||
async def test_llm_engine(
|
||||
engine_id: str,
|
||||
body: LLMEngineTestRequest,
|
||||
current_user: User = Depends(require_platform_ai_ops),
|
||||
current_user: User = Depends(get_current_user),
|
||||
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(require_platform_ai_ops),
|
||||
current_user: User = Depends(get_current_user),
|
||||
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,31 +20,25 @@ 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 {})
|
||||
dashscope_defaults = dict(provider_defaults.get("dashscope") or {})
|
||||
zhipu_defaults = dict(provider_defaults.get("zhipu") or {})
|
||||
glm_defaults = dict(provider_defaults.get("glm") 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 dashscope_defaults.get("model") or "qwen-plus").strip(),
|
||||
"low": str(low_tier.get("model") or glm_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": {
|
||||
"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(),
|
||||
"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(),
|
||||
},
|
||||
"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(),
|
||||
|
|
|
|||
|
|
@ -1,491 +0,0 @@
|
|||
"""模型能力矩阵 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,13 +34,12 @@ 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, permission_user)
|
||||
_ = current_user
|
||||
result = await db.execute(
|
||||
select(TruthNode).where(
|
||||
and_(
|
||||
|
|
@ -67,13 +66,12 @@ 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, permission_user)
|
||||
_ = current_user
|
||||
truth_ids_result = await db.execute(
|
||||
select(TruthNode.id).where(
|
||||
and_(
|
||||
|
|
@ -130,3 +128,4 @@ 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, require_project_member_or_manager
|
||||
from ...dependencies import get_current_user, get_tenant_id
|
||||
from ...models import Experiment, TruthNode, User
|
||||
from ...schemas.model_capability import (
|
||||
CostOptimizationAnalyzeRequest,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue